{
  "name": "Groky Kit 04 - Daily AI News Digest",
  "nodes": [
    {
      "parameters": {
        "width": 700,
        "height": 400,
        "content": "## 04 — Daily AI & Industry News Digest (v1.1 Free Sample)\n**What it does:** Every morning at 7am, pulls 6 AI/tech RSS feeds, skips links already sent, asks OpenAI (`gpt-5-mini`) to pick the 5 most useful stories for your role, and emails you a clean HTML digest.\n\n**Setup + Step 0 Preflight Check (5 min):**\n1. Open the yellow **Config** node → set `cfg_recipient_email` (your email), `cfg_role_focus`, and `cfg_feeds`.\n2. Open **Rank & Summarize (OpenAI)** → select your **OpenAI** credential (`openAiApi`).\n3. Open **Send Digest Email** → select your **Gmail OAuth2** credential (`gmailOAuth2`).\n4. **Step 0 Preflight Check:** Click *Execute workflow* before editing `Config` to see `Feed List` fail loudly (`Preflight check failed`), then fill in `cfg_recipient_email` and run again to receive your digest.\n5. Set an Error Workflow in **⋯ → Settings**, then click **Publish** (top right).\n\n**Want all 5 tested n8n AI agent workflows?** (Inbox Triage, Lead Follow-Up, Website Chatbot, AI News Digest, Content Repurposer): https://whop.com/jreamers-com/agent-starter-kit/ ($39 launch price through Oct 31)"
      },
      "id": "7e523350-1571-52c0-9431-9f787a8bd27f",
      "name": "Read Me",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -240,
        -300
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 7,
              "triggerAtMinute": 0
            }
          ]
        }
      },
      "id": "b579bb82-260b-511f-9b19-162110180584",
      "name": "Every Morning",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -240,
        200
      ]
    },
    {
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "c89a8f6d-6c19-5ec2-a872-987bc9f95ae6",
              "name": "cfg_recipient_email",
              "value": "REPLACE: you@yourdomain.com",
              "type": "string"
            },
            {
              "id": "03332c4a-f996-5e1f-8560-5ca5ed14ce84",
              "name": "cfg_digest_title",
              "value": "Your Daily AI Brief",
              "type": "string"
            },
            {
              "id": "edc5a6bf-40f2-5007-86c2-792664894c46",
              "name": "cfg_audience",
              "value": "solopreneurs and creators who use AI tools and agents to run online businesses",
              "type": "string"
            },
            {
              "id": "bab59b8a-da51-5567-8194-54eb59a6344c",
              "name": "cfg_keywords",
              "value": "ai, artificial intelligence, agent, llm, openai, anthropic, claude, gemini, gpt, model, automation, chatbot, machine learning",
              "type": "string"
            },
            {
              "id": "722a3922-9eb2-5b69-82d5-f7dddee6ea50",
              "name": "cfg_feeds",
              "value": "https://techcrunch.com/category/artificial-intelligence/feed/\nhttps://www.theverge.com/rss/ai-artificial-intelligence/index.xml\nhttps://www.technologyreview.com/topic/artificial-intelligence/feed\nhttps://openai.com/news/rss.xml\nhttps://huggingface.co/blog/feed.xml\nhttps://news.mit.edu/rss/topic/artificial-intelligence2",
              "type": "string"
            },
            {
              "id": "a0447bf0-5f62-5711-b0cc-ba45a716fb93",
              "name": "cfg_max_items",
              "value": 7,
              "type": "number"
            },
            {
              "id": "b40c5186-6f3f-58c9-9e46-ef93afc56828",
              "name": "cfg_max_candidates",
              "value": 40,
              "type": "number"
            },
            {
              "id": "aa9f54ce-0bdf-54f9-8241-c48a8144994b",
              "name": "cfg_max_age_hours",
              "value": 36,
              "type": "number"
            },
            {
              "id": "09238bcd-70c2-5f2b-8980-020a24b5f74c",
              "name": "cfg_dedupe_days",
              "value": 7,
              "type": "number"
            },
            {
              "id": "c2f66a12-082d-5fde-ad1f-2cd1a756fbad",
              "name": "cfg_openai_model",
              "value": "gpt-5-mini",
              "type": "string"
            },
            {
              "id": "300f513c-6f08-531c-9da4-4abcc7da4a5f",
              "name": "cfg_timezone",
              "value": "America/Chicago",
              "type": "string"
            }
          ]
        }
      },
      "id": "600c25d0-55fd-5cd9-abca-409db21d5486",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -20,
        200
      ],
      "executeOnce": true
    },
    {
      "parameters": {
        "jsCode": "const cfg = $('Config').first().json;\nconst rcpt = String(cfg.cfg_recipient_email || '').trim();\nif (!rcpt || /^REPLACE/i.test(rcpt) || !rcpt.includes('@')) {\n  throw new Error('Preflight check failed: open the Config node and set cfg_recipient_email to your email address (replace \"REPLACE: you@yourdomain.com\").');\n}\nif (!String(cfg.cfg_openai_model || '').trim() || /^REPLACE/i.test(String(cfg.cfg_openai_model))) {\n  throw new Error('Preflight check failed: open the Config node and set cfg_openai_model (for example gpt-5-mini).');\n}\nconst urls = String(cfg.cfg_feeds || '').split(/[\\n,]+/).map(s => s.trim()).filter(s => /^https?:\\/\\//.test(s));\nif (!urls.length) throw new Error('Preflight check failed: no valid https:// feed URLs found in Config > cfg_feeds.');\nreturn urls.map(url => ({ json: { url } }));"
      },
      "id": "386cea0a-d085-5443-adb1-40c9e657ccbf",
      "name": "Feed List",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        200,
        200
      ]
    },
    {
      "parameters": {
        "url": "={{ $json.url }}",
        "options": {}
      },
      "id": "32612808-6b18-5fad-953a-a99199335cae",
      "name": "Read RSS Feeds",
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1.2,
      "position": [
        420,
        200
      ],
      "onError": "continueRegularOutput",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "jsCode": "// Normalize, keep recent + on-topic, drop links already sent in previous digests.\nconst cfg = $('Config').first().json;\nconst store = $getWorkflowStaticData('global');\nstore.seen = store.seen || {};\nconst now = Date.now(), day = 86400000;\nfor (const [k, t] of Object.entries(store.seen)) if (now - t > (Number(cfg.cfg_dedupe_days) || 7) * day) delete store.seen[k];\nconst maxAgeMs = (Number(cfg.cfg_max_age_hours) || 36) * 3600000;\nconst kws = String(cfg.cfg_keywords || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);\n// (the Code sandbox has no URL class, so normalize with plain string ops)\nconst norm = (u) => {\n  u = String(u || '').trim(); if (!/^https?:\\/\\/[^\\s/]+/i.test(u)) return '';\n  u = u.split('#')[0];\n  const [base, qs] = u.split('?');\n  const kept = (qs || '').split('&').filter(p => p && !/^utm_/i.test(p));\n  return (base.replace(/\\/$/, '') + (kept.length ? '?' + kept.join('&') : ''));\n};\nconst host = (u) => (String(u).match(/^https?:\\/\\/([^/?#]+)/i) || [,''])[1].replace(/^www\\./, '');\nconst strip = (s) => String(s || '').replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&#8217;|&rsquo;/g, \"'\").replace(/&#8220;|&#8221;|&quot;/g, '\"').replace(/\\s+/g, ' ').trim();\nconst feeds = $('Feed List').all().map(f => f.json.url);\nconst stats = { fetched: 0, no_link: 0, too_old: 0, off_topic: 0, already_sent: 0, duplicate: 0 };\nconst seenNow = new Set(); const out = [];\nfor (const it of $input.all()) {\n  const j = it.json; if (j.error) continue;                       // a feed that failed to load\n  stats.fetched++;\n  const link = norm(j.link || j.guid || '');\n  if (!link) { stats.no_link++; continue; }\n  const ts = Date.parse(j.isoDate || j.pubDate || '') || 0;\n  if (!ts || now - ts > maxAgeMs) { stats.too_old++; continue; }\n  const title = strip(j.title); const snippet = strip(j.contentSnippet || j.content || j.summary || '').slice(0, 400);\n  const hay = (title + ' ' + snippet).toLowerCase();\n  if (kws.length && !kws.some(k => hay.includes(k))) { stats.off_topic++; continue; }\n  if (store.seen[link]) { stats.already_sent++; continue; }\n  if (seenNow.has(link)) { stats.duplicate++; continue; }\n  seenNow.add(link);\n  const source = host(link);\n  out.push({ title, link, snippet, source, published: new Date(ts).toISOString() });\n}\nout.sort((a, b) => b.published.localeCompare(a.published));\nconst candidates = out.slice(0, Number(cfg.cfg_max_candidates) || 40).map((c, i) => ({ id: i + 1, ...c }));\nconst prompt_list = candidates.map(c => `[${c.id}] ${c.title} (${c.source}, ${c.published.slice(0, 10)})\\n${c.snippet}`).join('\\n\\n');\nreturn [{ json: { candidates, prompt_list, count: candidates.length, feeds: feeds.length, stats } }];"
      },
      "id": "be30b5cb-e7eb-5e90-a49d-95d026ccbd26",
      "name": "Filter & Dedupe",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        640,
        200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.count }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "gt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "0555f045-8a24-5683-b630-556508c47507",
      "name": "Anything New?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        860,
        200
      ]
    },
    {
      "parameters": {
        "resource": "text",
        "operation": "response",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('Config').first().json.cfg_openai_model }}"
        },
        "responses": {
          "values": [
            {
              "type": "text",
              "role": "system",
              "content": "=You are the editor of a daily AI news digest for {{ $('Config').first().json.cfg_audience }}.\nYou receive a numbered list of real articles from RSS feeds. Pick the {{ $('Config').first().json.cfg_max_items }} most important and useful ones for this audience (prefer launches, research, tools and business moves over opinion; skip duplicates of the same story).\nFor each pick return its id, a short neutral headline, a 1-2 sentence summary, and one 'why it matters' sentence.\nSTRICT: use ONLY facts stated in that article's title and snippet. Do not add numbers, names, dates or claims that are not there. Never invent stories, never merge stories, and never output an id that is not in the list. If fewer items are worth including, return fewer."
            },
            {
              "type": "text",
              "role": "user",
              "content": "=Topics of interest: {{ $('Config').first().json.cfg_keywords }}\n\nArticles:\n{{ $json.prompt_list }}"
            }
          ]
        },
        "simplify": true,
        "options": {
          "textFormat": {
            "textOptions": {
              "type": "json_schema",
              "name": "digest_picks",
              "schema": "{\n  \"type\": \"object\",\n  \"additionalProperties\": false,\n  \"required\": [\n    \"picks\"\n  ],\n  \"properties\": {\n    \"picks\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"additionalProperties\": false,\n        \"required\": [\n          \"id\",\n          \"headline\",\n          \"summary\",\n          \"why_it_matters\"\n        ],\n        \"properties\": {\n          \"id\": {\n            \"type\": \"integer\"\n          },\n          \"headline\": {\n            \"type\": \"string\"\n          },\n          \"summary\": {\n            \"type\": \"string\"\n          },\n          \"why_it_matters\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    }\n  }\n}",
              "strict": true
            }
          },
          "maxTokens": 2500
        }
      },
      "id": "2445fe7b-52e9-5415-8be7-72353b1cc4b7",
      "name": "Pick & Summarize",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 2.1,
      "position": [
        1080,
        120
      ],
      "credentials": {
        "openAiApi": {
          "id": "REPLACE_ME",
          "name": "OpenAI account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "jsCode": "// Map the AI's picks back to the feed items. Links ALWAYS come from the feed, never from the AI.\nconst cfg = $('Config').first().json;\nconst f = $('Filter & Dedupe').first().json;\nconst byId = Object.fromEntries(f.candidates.map(c => [c.id, c]));\nconst outArr = $input.first().json.output;\nconst msgItem = Array.isArray(outArr) ? (outArr.find(o => o && o.type === 'message') || outArr[0]) : null;\nlet ai = msgItem?.content?.[0]?.text ?? $input.first().json.text;\nif (typeof ai === 'string') { try { ai = JSON.parse(ai); } catch (e) { ai = null; } }\nconst picks = []; const rejected = []; const used = new Set();\nfor (const p of (ai?.picks || [])) {\n  const c = byId[p.id];\n  if (!c || used.has(p.id)) { rejected.push(p.id); continue; }\n  used.add(p.id);\n  picks.push({ ...c, headline: String(p.headline || c.title).trim(), summary: String(p.summary || '').trim(), why: String(p.why_it_matters || '').trim() });\n  if (picks.length >= (Number(cfg.cfg_max_items) || 7)) break;\n}\nif (!picks.length) throw new Error('AI returned no usable picks' + (rejected.length ? ` (rejected ids: ${rejected.join(',')})` : ''));\nconst esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;');\nconst tz = cfg.cfg_timezone || 'America/Chicago';\nconst dateStr = new Date().toLocaleDateString('en-US', { timeZone: tz, weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });\nconst itemsHtml = picks.map((p, i) => `\n  <tr><td style=\"padding:16px 0;border-bottom:1px solid #eee;\">\n    <div style=\"font-size:12px;color:#888;text-transform:uppercase;letter-spacing:.5px;\">${i + 1} · ${esc(p.source)}</div>\n    <a href=\"${esc(p.link)}\" style=\"font-size:17px;font-weight:600;color:#111;text-decoration:none;line-height:1.35;\">${esc(p.headline)}</a>\n    <p style=\"margin:6px 0 4px;font-size:14px;color:#333;line-height:1.5;\">${esc(p.summary)}</p>\n    <p style=\"margin:0;font-size:14px;color:#555;line-height:1.5;\"><b>Why it matters:</b> ${esc(p.why)}</p>\n    <a href=\"${esc(p.link)}\" style=\"font-size:13px;color:#2563eb;\">Read at ${esc(p.source)} →</a>\n  </td></tr>`).join('');\nconst html = `<!doctype html><html><body style=\"margin:0;background:#f6f6f6;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;\">\n<table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"background:#f6f6f6;\"><tr><td align=\"center\" style=\"padding:24px 12px;\">\n<table width=\"600\" cellpadding=\"0\" cellspacing=\"0\" style=\"max-width:600px;width:100%;background:#fff;border-radius:10px;padding:24px;\">\n  <tr><td><div style=\"font-size:22px;font-weight:700;color:#111;\">${esc(cfg.cfg_digest_title)}</div>\n  <div style=\"font-size:13px;color:#888;margin-top:4px;\">${esc(dateStr)} · ${picks.length} stories from ${f.count} new articles</div></td></tr>\n  ${itemsHtml}\n  <tr><td style=\"padding-top:16px;font-size:12px;color:#999;line-height:1.5;\">Summaries are AI-written from each article's RSS title and snippet; always check the source. Links go to the original publishers.</td></tr>\n</table></td></tr></table></body></html>`;\nconst text = `${cfg.cfg_digest_title} (${dateStr})\\n\\n` + picks.map((p, i) => `${i + 1}. ${p.headline}\\n${p.summary}\\nWhy it matters: ${p.why}\\n${p.link}`).join('\\n\\n');\n// remember what we sent so tomorrow's digest skips it\nconst store = $getWorkflowStaticData('global'); store.seen = store.seen || {};\nfor (const p of picks) store.seen[p.link] = Date.now();\nreturn [{ json: { subject: `${cfg.cfg_digest_title}: ${picks[0].headline}`, html, text, picks, rejected_ids: rejected, candidate_count: f.count, stats: f.stats } }];"
      },
      "id": "be147d86-84f5-5744-9768-99bc0efeafa5",
      "name": "Build Digest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1300,
        120
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "send",
        "sendTo": "={{ $('Config').first().json.cfg_recipient_email }}",
        "subject": "={{ $json.subject }}",
        "emailType": "html",
        "message": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "ecd74dcb-3dbb-521e-8b50-30997fd93306",
      "name": "Send Digest Email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1520,
        0
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_ME",
          "name": "Gmail account"
        }
      }
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#ai-news"
        },
        "text": "={{ $('Build Digest').first().json.text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "ac3976ef-051f-5d88-9db1-12a68a6416d4",
      "name": "Post to Slack (optional)",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1520,
        160
      ],
      "credentials": {
        "slackApi": {
          "id": "REPLACE_ME",
          "name": "Slack account"
        }
      },
      "disabled": true
    },
    {
      "parameters": {
        "chatId": "REPLACE_WITH_CHAT_ID",
        "text": "={{ $('Build Digest').first().json.text.slice(0, 4000) }}",
        "additionalFields": {
          "appendAttribution": false,
          "disable_web_page_preview": true
        }
      },
      "id": "921ac3ca-0295-5614-a8e9-bc89f9704434",
      "name": "Post to Telegram (optional)",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1520,
        320
      ],
      "credentials": {
        "telegramApi": {
          "id": "REPLACE_ME",
          "name": "Telegram account"
        }
      },
      "disabled": true
    }
  ],
  "connections": {
    "Every Morning": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Feed List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Feed List": {
      "main": [
        [
          {
            "node": "Read RSS Feeds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read RSS Feeds": {
      "main": [
        [
          {
            "node": "Filter & Dedupe",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter & Dedupe": {
      "main": [
        [
          {
            "node": "Anything New?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anything New?": {
      "main": [
        [
          {
            "node": "Pick & Summarize",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Pick & Summarize": {
      "main": [
        [
          {
            "node": "Build Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Digest": {
      "main": [
        [
          {
            "node": "Send Digest Email",
            "type": "main",
            "index": 0
          },
          {
            "node": "Post to Slack (optional)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Post to Telegram (optional)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "America/Chicago"
  },
  "active": false,
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}