{
  "name": "Record expense and sales notes from Chatwork in Google Sheets with Claude, checked by code",
  "nodes": [
    {
      "id": "c8836096-30f4-4daa-87b3-4cc590f30708",
      "name": "Every 5 minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.4,
      "position": [
        0,
        -100
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 5
            }
          ]
        }
      }
    },
    {
      "id": "5863c11c-2758-4ce3-8872-36c6af3852c7",
      "name": "Run now",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        100
      ],
      "parameters": {}
    },
    {
      "id": "08b5cb20-a7e3-40fc-897c-e7374030ad91",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "c1",
              "name": "room_ids",
              "value": "123456789",
              "type": "string"
            },
            {
              "id": "c2",
              "name": "keyword",
              "value": "#記録",
              "type": "string"
            },
            {
              "id": "c3",
              "name": "strip_keyword",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "c4",
              "name": "categories",
              "value": "交通費, 会議費, 接待交際費, 消耗品費, 通信費, 外注費, 図書費, 地代家賃, 売上, その他",
              "type": "string"
            },
            {
              "id": "c5",
              "name": "category_hints",
              "value": "交通費: 電車・バス・タクシー・ガソリン・駐車場・高速代\n会議費: 社内や打ち合わせの飲み物・弁当・会議室\n接待交際費: 取引先との飲食・贈答\n消耗品費: 文房具・備品・周辺機器・日用品\n通信費: 携帯・インターネット・サーバー・切手・郵送\n外注費: 業務委託・デザイン・制作の支払い\n図書費: 書籍・雑誌・資料\n地代家賃: 事務所・駐車場の月極・レンタルスペース\n売上: 商品やサービスの売上・入金\nその他: 上のどれにも当てはまらないもの",
              "type": "string"
            },
            {
              "id": "c6",
              "name": "reply_in_chatwork",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "c7",
              "name": "reply_marker",
              "value": "【自動記録】",
              "type": "string"
            },
            {
              "id": "c8",
              "name": "max_age_hours",
              "value": 24,
              "type": "number"
            },
            {
              "id": "c9",
              "name": "include_system",
              "value": false,
              "type": "boolean"
            },
            {
              "id": "c10",
              "name": "timezone",
              "value": "Asia/Tokyo",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "41eea0d8-2eec-40e2-8d3c-8aed064a7589",
      "name": "Get room names",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        440,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "https://api.chatwork.com/v2/rooms",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "executeOnce": true
    },
    {
      "id": "7452c2a1-76a7-4046-8077-ac99adf05b18",
      "name": "Split rooms",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        0
      ],
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\n// An unknown ID is kept (the name says so) so that a typo is visible in the sheet.\nconst names = new Map($input.all().map(i => i.json).filter(r => r.room_id).map(r => [String(r.room_id), r.name]));\nconst ids = String(cfg.room_ids ?? '').split(/[\\s,]+/).filter(Boolean);\nreturn ids.map(id => ({ json: { room_id: id, room_name: names.get(id) ?? '(unknown room)' } }));"
      }
    },
    {
      "id": "694e570d-c2c9-4ec8-82ee-8600e7995c16",
      "name": "Get messages",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        880,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "=https://api.chatwork.com/v2/rooms/{{ $json.room_id }}/messages?force=1",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "d48fc2fd-2690-4d1d-8d7d-8b8fb99b7d75",
      "name": "Add room",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": true,
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "room_id",
              "value": "={{ $(\"Split rooms\").item.json.room_id }}",
              "type": "string"
            },
            {
              "id": "r2",
              "name": "room_name",
              "value": "={{ $(\"Split rooms\").item.json.room_name }}",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "84387665-e6e4-4920-80de-388d3da24cb0",
      "name": "Clean messages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1480,
        0
      ],
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst keyword = String(cfg.keyword ?? '').trim();\nconst marker = String(cfg.reply_marker ?? '').trim();\nconst maxAge = Number(cfg.max_age_hours) || 0;\nconst nowSec = Math.floor(Date.now() / 1000);\nconst tz = cfg.timezone || 'Asia/Tokyo';\n\n// Chatwork writes mentions, replies, quotes and blocks as [tags]. Turn them into plain text and keep what is useful.\nfunction toPlain(body) {\n  const mentions = [];\n  let replyTo = '';\n  let s = body;\n  s = s.replace(/\\[To:(\\d+)\\]\\s*/g, (_, id) => { mentions.push(id); return '@'; });\n  s = s.replace(/\\[rp aid=(\\d+) to=([\\d-]+)\\]\\s*/g, (_, aid, to) => { replyTo = to; return '(reply) '; });\n  s = s.replace(/\\[qt\\]\\[qtmeta[^\\]]*\\]([\\s\\S]*?)\\[\\/qt\\]\\s*/g, (_, q) => q.trim().split('\\n').map(l => '> ' + l).join('\\n') + '\\n');\n  s = s.replace(/\\[title\\]([\\s\\S]*?)\\[\\/title\\]/g, '[$1] ');\n  s = s.replace(/\\[\\/?(?:info|code|hr)\\]/g, '');\n  s = s.replace(/\\[\\/?[a-z]+(?:[: ][^\\]]*)?\\]/g, '');\n  return { text: s.trim(), mentions, replyTo };\n}\n\nconst out = [];\nfor (const it of $input.all()) {\n  const m = it.json;\n  if (!m.message_id || typeof m.body !== 'string') continue; // an error item or an empty room\n  // system messages (room created, task added...) look like [info][title][dtext:...]\n  if (!cfg.include_system && /\\[dtext:/.test(m.body)) continue;\n  // our own confirmations carry the marker; they are not notes\n  if (marker && m.body.includes(marker)) continue;\n  if (maxAge && nowSec - Number(m.send_time) > maxAge * 3600) continue;\n  const p = toPlain(m.body);\n  const isNote = !!keyword && m.body.includes(keyword);\n  const text = isNote && cfg.strip_keyword ? p.text.split(keyword).join('').trim() : p.text;\n  const sentAt = DateTime.fromSeconds(Number(m.send_time), { zone: tz }).toFormat('yyyy-MM-dd HH:mm:ss');\n  out.push({ json: {\n    message_id: String(m.message_id),\n    room_id: String(m.room_id),\n    room_name: m.room_name,\n    sent_at: sentAt,\n    sent_date: sentAt.slice(0, 10),\n    sender_id: String(m.account?.account_id ?? ''),\n    sender_name: m.account?.name ?? '',\n    text,\n    mentions: p.mentions.join(','),\n    reply_to: p.replyTo,\n    edited: Number(m.update_time) > 0,\n    raw: m.body,\n    is_note: isNote,\n  } });\n}\nreturn out;"
      }
    },
    {
      "id": "95644712-ee48-464d-848e-fae89235b61f",
      "name": "Keep new messages only",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        1700,
        -200
      ],
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "logic": "removeItemsWithAlreadySeenKeyValues",
        "dedupeValue": "={{ $json.room_id + \"-\" + $json.message_id }}",
        "options": {}
      }
    },
    {
      "id": "59d2f234-3f24-4d81-85dc-5f14309fbfc5",
      "name": "Add to log sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1920,
        -200
      ],
      "parameters": {
        "resource": "sheet",
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "log"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "message_id"
          ],
          "schema": []
        },
        "options": {}
      }
    },
    {
      "id": "7e3dfea5-4016-407f-83d2-4bda1e4d862c",
      "name": "Is it an accounting note?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2300,
        200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "n1",
              "leftValue": "={{ $json.is_note }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "2c8e4e58-489c-4963-8154-de91be5de418",
      "name": "AI: Extract records",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        2960,
        200
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-haiku-4-5-20251001"
        },
        "messages": {
          "values": [
            {
              "content": "=You read one short accounting note that someone wrote in a team chat and turn it into records. Use ONLY the words of the note.\n\n# Rules\n- A note can hold several records (for example two receipts). If the note has no amount or is not an accounting note, return an empty list.\n- type: \"expense\", \"income\" (sales, money received) or \"other\".\n- amount: a number in yen as written (3万5千円 -> 35000). If the note says it is a refund, use a negative number. Never calculate a total that is not written.\n- currency: \"JPY\" unless the note names another currency.\n- date_text: the words that give the date, copied exactly from the note (for example 昨日, 10/3, 先週金曜). Empty string if there are none. Do not turn it into a date.\n- category: exactly one of the names below (each line is \"name: examples\"):\n{{ $(\"Config\").first().json.category_hints }}\n- vendor: the shop or company name copied exactly from the note, or an empty string.\n- memo: a few words copied from the note.\n- quote: the exact part of the note that states the amount, copied exactly.\n- Do not invent anything. Output JSON only (no explanation, no code block).\n\n# Output schema\n{\"records\": [{\"type\": \"expense\", \"amount\": 3200, \"currency\": \"JPY\", \"date_text\": \"昨日\", \"category\": \"交通費\", \"vendor\": \"\", \"memo\": \"タクシー\", \"quote\": \"3,200円\"}]}\n\n# Note\n{{ $json.text }}",
              "role": "user"
            }
          ]
        },
        "simplify": true,
        "options": {
          "maxTokens": 1500,
          "temperature": 0
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "b395a246-d9de-4b65-8016-0fc86f1c5637",
      "name": "Read recorded notes",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        2520,
        200
      ],
      "parameters": {
        "resource": "sheet",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "records"
        },
        "options": {}
      },
      "executeOnce": true,
      "alwaysOutputData": true
    },
    {
      "id": "bc805a52-fdca-4ae6-806f-60ab09bc3b3a",
      "name": "Skip notes already recorded",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2740,
        200
      ],
      "parameters": {
        "jsCode": "// Notes that already have a row in the records tab are not read by the AI again (and get no second reply),\n// even when the \"already seen\" history is gone because the workflow was imported again.\nconst recorded = new Set($input.all().map(i => String(i.json.message_id ?? '')).filter(Boolean));\nreturn $('Is it an accounting note?').all().filter(i => !recorded.has(String(i.json.message_id)));"
      }
    },
    {
      "id": "f8823f64-21eb-438e-839a-833dedfeaf50",
      "name": "Check records",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3340,
        200
      ],
      "parameters": {
        "jsCode": "// ---- guard for AI-extracted accounting notes -------------------------------------------------\nconst nfkc = (s) => String(s ?? '').normalize('NFKC');\nconst squash = (s) => nfkc(s).replace(/\\s+/g, '');\n\n// \"3万5千\" -> 35000, \"1.5万\" -> 15000, \"3200\" -> 3200\nfunction parseYen(str) {\n  let total = 0;\n  const man = str.match(/(\\d+(?:\\.\\d+)?)万/);\n  const sen = str.match(/(\\d+(?:\\.\\d+)?)千/);\n  if (man) total += Number(man[1]) * 10000;\n  if (sen) total += Number(sen[1]) * 1000;\n  const rest = str.replace(/(\\d+(?:\\.\\d+)?)[万千]/g, '').match(/\\d+(?:\\.\\d+)?/);\n  if (rest) total += Number(rest[0]);\n  return Math.round(total);\n}\n\n// Every amount that is really written in the message (numbers next to 円 / yen / ¥). If none is marked, fall back to bare numbers.\nfunction amountCandidates(text) {\n  const t = nfkc(text).replace(/(?<=\\d),(?=\\d{3}(?!\\d))/g, '');\n  const token = '((?:\\\\d+(?:\\\\.\\\\d+)?[万千])*(?:\\\\d+(?:\\\\.\\\\d+)?)?)';\n  const found = [];\n  for (const re of [new RegExp('[¥￥]\\\\s*' + token, 'g'), new RegExp(token + '\\\\s*(?:円|yen|JPY|えん)', 'gi')]) {\n    for (const m of t.matchAll(re)) if (m[1] && /\\d/.test(m[1])) found.push(parseYen(m[1]));\n  }\n  if (found.length) return { values: [...new Set(found)], marked: true };\n  const bare = (t.match(/\\d+(?:\\.\\d+)?/g) ?? []).map(Number);\n  return { values: [...new Set(bare)], marked: false };\n}\n\nconst WD = { 月: 0, 火: 1, 水: 2, 木: 3, 金: 4, 土: 5, 日: 6 };\nconst iso = (d) => d.toISOString().slice(0, 10);\nconst addDays = (d, n) => new Date(d.getTime() + n * 86400000);\n\n// Resolve the date words of a note against the day it was sent (base = \"yyyy-MM-dd\"). Returns null if the words are not understood.\nfunction resolveDate(words, base) {\n  const w = nfkc(words).trim();\n  const [by, bm, bd] = base.split('-').map(Number);\n  const b = new Date(Date.UTC(by, bm - 1, bd));\n  if (!w) return base;\n  let m;\n  if ((m = w.match(/(\\d{4})[\\/\\-.年](\\d{1,2})[\\/\\-.月](\\d{1,2})/))) return iso(new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])));\n  if ((m = w.match(/(\\d{1,2})[\\/月](\\d{1,2})日?/))) {\n    let d = new Date(Date.UTC(by, +m[1] - 1, +m[2]));\n    if (d.getTime() - b.getTime() > 31 * 86400000) d = new Date(Date.UTC(by - 1, +m[1] - 1, +m[2]));\n    return iso(d);\n  }\n  if (/一昨日|おととい/.test(w)) return iso(addDays(b, -2));\n  if (/昨日|きのう/.test(w)) return iso(addDays(b, -1));\n  if (/今日|本日|きょう/.test(w)) return base;\n  if (/明日/.test(w)) return iso(addDays(b, 1));\n  if ((m = w.match(/(\\d+)日前/))) return iso(addDays(b, -Number(m[1])));\n  if ((m = w.match(/(先週|今週)の?([月火水木金土日])/))) {\n    const mondayIdx = (b.getUTCDay() + 6) % 7;\n    const monday = addDays(b, -mondayIdx);\n    return iso(addDays(monday, WD[m[2]] - (m[1] === '先週' ? 7 : 0)));\n  }\n  return null;\n}\n\n// A note that states an amount but produced no record is a silent miss. Report it instead of dropping it.\nfunction checkNote(records, text) {\n  if (!records.length && amountCandidates(text).marked) return ['an amount is written in the message, but no record was extracted'];\n  return [];\n}\n\n// Check one extracted record against the message. Nothing is dropped: the caller records problems as \"needs review\".\nfunction checkRecord(rec, text, categories, baseDate) {\n  const problems = [];\n  const src = squash(text);\n  const cand = amountCandidates(text);\n  const amount = Number(rec.amount);\n  if (!Number.isFinite(amount) || amount === 0) problems.push('amount is missing');\n  else if (!cand.values.includes(Math.abs(amount))) problems.push('amount ' + amount + ' is not written in the message');\n  else if (!cand.marked) problems.push('the message has no 円 / yen next to the amount');\n  if (rec.currency && rec.currency !== 'JPY') problems.push('currency is ' + rec.currency + ', not JPY');\n  if (!rec.quote || !src.includes(squash(rec.quote))) problems.push('the quote is not in the message');\n  const words = String(rec.date_text ?? '').trim();\n  if (words && !src.includes(squash(words))) problems.push('the date words are not in the message');\n  let date = resolveDate(words, baseDate);\n  if (date === null) { problems.push('date \"' + words + '\" not understood (sent date used)'); date = baseDate; }\n  if (rec.vendor && !src.includes(squash(rec.vendor))) problems.push('vendor is not in the message');\n  const category = categories.includes(rec.category) ? rec.category : 'その他';\n  if (!categories.includes(rec.category)) problems.push('category \"' + rec.category + '\" is not in the list');\n  if (!['expense', 'income', 'other'].includes(rec.type)) problems.push('type is not expense / income / other');\n  return { problems, date, category, amount: Math.abs(amount) || 0 };\n}\n\nconst cats = String($('Config').first().json.categories).split(/\\s*,\\s*/).filter(Boolean);\nconst notes = $('Skip notes already recorded').all().map(i => i.json);\nconst TYPE_JA = { expense: '経費', income: '売上', other: 'その他' };\n// The guard speaks English (it is easier to test); the sheet and the Chatwork reply are for Japanese readers.\nconst JA_RULES = [\n  [/^an amount is written in the message, but no record was extracted/, '金額が書いてあるのに、記録を作れませんでした'],\n  [/^no amount was found/, '金額が見つかりませんでした'],\n  [/^the AI output could not be read/, 'AI の出力を読めませんでした'],\n  [/^amount .* is not written in the message/, '金額が書き込みの中に見つかりません'],\n  [/^amount is missing/, '金額がありません'],\n  [/^the message has no 円/, '金額の横に「円」の表記がありません'],\n  [/^currency is/, '日本円ではない通貨です'],\n  [/^the quote is not in the message/, '金額の引用が書き込みに含まれていません'],\n  [/^the date words are not in the message/, '日付の言葉が書き込みに含まれていません'],\n  [/^date \".*\" not understood/, '日付の言葉が分かりません（送信日を使いました）'],\n  [/^vendor is not in the message/, '取引先名が書き込みにありません'],\n  [/^category .* is not in the list/, '科目が一覧にありません（その他にしました）'],\n  [/^type is not/, '種類（経費・売上）が分かりません'],\n];\nconst ja = (list) => list.map(p => (JA_RULES.find(([re]) => re.test(p)) ?? [null, p])[1]).join('、');\n\nconst rows = [];\n$input.all().forEach((it, i) => {\n  const note = notes[i];\n  let records = [];\n  let problems0 = [];\n  try {\n    const rawText = it.json.content?.[0]?.text ?? it.json.text;\n    // Claude has no JSON mode, so it may wrap the JSON in a code fence. Strip it before parsing.\n    const t = String(rawText ?? '').replace(/^\\s*```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/, '').trim();\n    records = JSON.parse(t).records ?? [];\n  } catch (e) { problems0 = ['the AI output could not be read']; }\n  const base = {\n    message_id: note.message_id, room_id: note.room_id, room_name: note.room_name, sent_at: note.sent_at,\n    sender_id: note.sender_id, sender_name: note.sender_name, text: note.text,\n  };\n  const noteProblems = problems0.length ? problems0 : checkNote(records, note.text);\n  if (!records.length) {\n    // nothing was extracted: say so in the sheet instead of dropping the note\n    rows.push({ json: { ...base, record_id: note.message_id + '-1', type: '', amount: '', date: '', category: '', vendor: '', memo: '', quote: '',\n      status: '要確認', problems: ja(noteProblems.length ? noteProblems : ['no amount was found in the message']) } });\n    return;\n  }\n  records.forEach((r, k) => {\n    const c = checkRecord(r, note.text, cats, note.sent_date);\n    rows.push({ json: { ...base, record_id: note.message_id + '-' + (k + 1),\n      type: TYPE_JA[r.type] ?? '', amount: Number(r.amount) < 0 ? -c.amount : c.amount, date: c.date, category: c.category,\n      vendor: r.vendor ?? '', memo: r.memo ?? '', quote: r.quote ?? '',\n      status: c.problems.length ? '要確認' : 'OK', problems: ja(c.problems) } });\n  });\n});\nreturn rows;"
      }
    },
    {
      "id": "215d8887-3f91-49a4-8f42-19bdb8990454",
      "name": "Add to records sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        3560,
        200
      ],
      "parameters": {
        "resource": "sheet",
        "operation": "appendOrUpdate",
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "records"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [
            "record_id"
          ],
          "schema": []
        },
        "options": {}
      }
    },
    {
      "id": "3f19ff5d-730d-431c-8908-f8fbab511f5f",
      "name": "Build Chatwork reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3940,
        200
      ],
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nif (!cfg.reply_in_chatwork) return [];\nconst marker = String(cfg.reply_marker ?? '');\n\n// one confirmation per note, even when the note produced several records\nconst byNote = new Map();\nfor (const it of $input.all()) {\n  const r = it.json;\n  if (!byNote.has(r.message_id)) byNote.set(r.message_id, []);\n  byNote.get(r.message_id).push(r);\n}\nconst yen = (n) => Number(n).toLocaleString('ja-JP') + '円';\nconst out = [];\nfor (const [messageId, rows] of byNote) {\n  const first = rows[0];\n  const lines = rows.map(r => {\n    const what = r.amount === '' ? '(記録できませんでした)' : [r.type, yen(r.amount), r.category, r.date, r.memo ? '(' + r.memo + ')' : ''].filter(Boolean).join(' ');\n    return (r.status === 'OK' ? '✅ ' : '⚠ 要確認: ') + what + (r.problems ? '\\n   理由: ' + r.problems : '');\n  });\n  const body = '[rp aid=' + first.sender_id + ' to=' + first.room_id + '-' + messageId + ']' + marker + '\\n' + lines.join('\\n');\n  out.push({ json: { room_id: first.room_id, message_id: messageId, body } });\n}\nreturn out;"
      }
    },
    {
      "id": "980c8ff2-7f91-49be-89d1-627ed55d530d",
      "name": "Reply in Chatwork",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        4160,
        200
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://api.chatwork.com/v2/rooms/{{ $json.room_id }}/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "form-urlencoded",
        "specifyBody": "keypair",
        "bodyParameters": {
          "parameters": [
            {
              "name": "body",
              "value": "={{ $json.body }}"
            },
            {
              "name": "self_unread",
              "value": "1"
            }
          ]
        },
        "options": {}
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "c3665669-10f3-4666-84d8-86cc4f3aa412",
      "name": "Run once: set up the sheet",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        450
      ],
      "parameters": {}
    },
    {
      "id": "a770674f-8fb1-4c26-82eb-956bc27bd734",
      "name": "Add the log and records tabs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        440,
        450
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $(\"Spreadsheet ID\").first().json.spreadsheet_id }}:batchUpdate",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={\"requests\":[{\"addSheet\":{\"properties\":{\"title\":\"log\"}}},{\"addSheet\":{\"properties\":{\"title\":\"records\"}}}]}",
        "options": {}
      }
    },
    {
      "id": "185e4255-049d-4031-8795-f8409aa68c07",
      "name": "Write the header rows",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        660,
        450
      ],
      "parameters": {
        "method": "POST",
        "url": "=https://sheets.googleapis.com/v4/spreadsheets/{{ $(\"Spreadsheet ID\").first().json.spreadsheet_id }}/values:batchUpdate",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={\"valueInputOption\":\"RAW\",\"data\":[{\"range\":\"'log'!A1\",\"values\":[[\"message_id\",\"room_id\",\"room_name\",\"sent_at\",\"sent_date\",\"sender_id\",\"sender_name\",\"text\",\"mentions\",\"reply_to\",\"edited\",\"raw\",\"is_note\"]]},{\"range\":\"'records'!A1\",\"values\":[[\"record_id\",\"message_id\",\"room_id\",\"room_name\",\"sent_at\",\"sender_id\",\"sender_name\",\"type\",\"amount\",\"date\",\"category\",\"vendor\",\"memo\",\"quote\",\"status\",\"problems\",\"text\"]]}]}",
        "options": {}
      }
    },
    {
      "id": "b0b0b0b0-0000-4000-8000-0000000000a1",
      "name": "Spreadsheet ID",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        450
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "sid1",
              "name": "spreadsheet_id",
              "value": "PASTE_YOUR_SPREADSHEET_ID_HERE",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "parameters": {
        "content": "## 1. Read the Chatwork rooms\nReads the latest messages of each room you list. Rooms are handled by their position, so a message always keeps its own room name.",
        "height": 528,
        "width": 1300,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -258
      ],
      "id": "sticky-2-071a86a1",
      "name": "Section 1"
    },
    {
      "parameters": {
        "content": "## 2. Clean the messages and log them\nTurns Chatwork tags (mentions, replies, quotes, blocks) into plain text and skips system messages and our own replies. New messages are added to the log tab.",
        "height": 554,
        "width": 640,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1420,
        -384
      ],
      "id": "sticky-3-a273e201",
      "name": "Section 2"
    },
    {
      "parameters": {
        "content": "## 3. Let Claude read the notes\nOnly messages with the keyword go on. Notes already in the records tab are skipped, so a failed run cannot lose a note and nothing is read twice.",
        "height": 328,
        "width": 860,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2240,
        42
      ],
      "id": "sticky-4-a5c27201",
      "name": "Section 3"
    },
    {
      "parameters": {
        "content": "## 4. Check by code and record\nThe amount, quote, currency and category are checked against the note; date words become a date in code; an amount with no record is flagged. Doubtful rows are saved as 要確認.",
        "height": 406,
        "width": 420,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3280,
        -36
      ],
      "id": "sticky-5-2d690055",
      "name": "Section 4"
    },
    {
      "parameters": {
        "content": "## 5. Reply in Chatwork\nOne reply per note says what was recorded or why it needs a look. The reply carries a marker, so it is never read as a note.",
        "height": 380,
        "width": 420,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3880,
        -10
      ],
      "id": "sticky-6-009d8310",
      "name": "Section 5"
    },
    {
      "parameters": {
        "content": "## Run once: set up the sheet\nPaste your spreadsheet ID, run this branch once, and it adds the two tabs and the header rows. Running it twice fails, because the tabs exist.",
        "height": 328,
        "width": 860,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        292
      ],
      "id": "sticky-7-6a05ac8f",
      "name": "Section 6"
    },
    {
      "parameters": {
        "content": "## Chatwork notes into a checked Google Sheet\nWrite \"#記録 昨日 タクシー 3,200円\" in a Chatwork room. Claude splits it into date, amount, category and memo, code checks the result against your note, and the row lands in Google Sheets. A reply in the room says what was recorded.\n\n### How it works\n1. Every 5 minutes it reads the latest 100 messages of the rooms you list (Chatwork has no n8n node, so this uses the API). Every message is cleaned and logged in the `log` tab.\n2. A message that contains the keyword (default #記録) is read by Claude.\n3. **Code checks every record:** the amount must be written in the note, the date words are turned into a date by code (昨日, 10/3, 先週金曜), the category must be in your list. Anything doubtful is saved as 要確認 with the reason, never dropped.\n4. Rows go to the `records` tab. One reply per note is posted in Chatwork.\n\n### Setup\n1. Create a blank Google Sheet. Paste its ID in **Spreadsheet ID**, run the **Run once** branch (it adds the `log` and `records` tabs and the header rows), and select the sheet in the three Google Sheets nodes.\n2. Credentials: Header Auth for Chatwork (Name `x-chatworktoken`, Value = your API token only, no \"Bearer\"), Google Sheets OAuth2, Anthropic.\n3. Put your room IDs in **Config** (the number after `#!rid` in the room URL). Edit the category list.\n4. Publish.\n\n### Customization\nChange the keyword, the categories and their examples, or turn off replies in **Config**.",
        "height": 860,
        "width": 780,
        "color": 1
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -130,
        -1384
      ],
      "id": "sticky-1-f3895132",
      "name": "Overview"
    }
  ],
  "connections": {
    "Every 5 minutes": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run now": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Get room names",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get room names": {
      "main": [
        [
          {
            "node": "Split rooms",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split rooms": {
      "main": [
        [
          {
            "node": "Get messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get messages": {
      "main": [
        [
          {
            "node": "Add room",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add room": {
      "main": [
        [
          {
            "node": "Clean messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clean messages": {
      "main": [
        [
          {
            "node": "Keep new messages only",
            "type": "main",
            "index": 0
          },
          {
            "node": "Is it an accounting note?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep new messages only": {
      "main": [
        [
          {
            "node": "Add to log sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is it an accounting note?": {
      "main": [
        [
          {
            "node": "Read recorded notes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read recorded notes": {
      "main": [
        [
          {
            "node": "Skip notes already recorded",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip notes already recorded": {
      "main": [
        [
          {
            "node": "AI: Extract records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI: Extract records": {
      "main": [
        [
          {
            "node": "Check records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check records": {
      "main": [
        [
          {
            "node": "Add to records sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add to records sheet": {
      "main": [
        [
          {
            "node": "Build Chatwork reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Chatwork reply": {
      "main": [
        [
          {
            "node": "Reply in Chatwork",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run once: set up the sheet": {
      "main": [
        [
          {
            "node": "Spreadsheet ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add the log and records tabs": {
      "main": [
        [
          {
            "node": "Write the header rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Spreadsheet ID": {
      "main": [
        [
          {
            "node": "Add the log and records tabs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
