{
  "name": "Score journalist requests and draft the pitch reply with Apify, OpenAI and Telegram",
  "nodes": [
    {
      "parameters": {
        "content": "## Score journalist requests and draft the pitch reply for you\n\nHARO closed in December 2024 and the source requests that replaced it are scattered across SourceBottle and the #journorequest hashtag. Finding them is only half the work: most are not worth your time, and the ones that are still need a reply written before the deadline.\n\nThis workflow reads every open request each morning, scores it against your actual expertise, and writes the reply for the ones worth answering. You read a finished draft in Telegram and decide whether to send it.\n\n### Who it's for\nFounders, consultants, SEO link builders and PR people who want press mentions but will not spend an hour a day reading call-outs and writing pitches from scratch.\n\n### How it works\n1. **Schedule** runs the workflow every morning at 8am.\n2. **Set your profile** holds who you are, what makes you quotable and how strict to be.\n3. **Apify** runs the [Journalist Request Finder](https://apify.com/fayoussef/journalist-request-finder?fpr=youssef) across SourceBottle, Bluesky and Mastodon.\n4. **Remove Duplicates** drops anything you have already been shown.\n5. **OpenAI scores each request** 0 to 100 on whether you can answer it with first-hand authority, and names the single angle you should lead with. Every request and its score is saved to Google Sheets, including the rejects, so you can see what it filtered and tune `minScore`.\n6. Requests at or above `minScore` go through, capped at `maxPitches` a day.\n7. **OpenAI drafts the reply** in your voice: 90 words, one concrete detail from your credentials, no flattery.\n8. Each draft arrives in Telegram as its own message, ready to copy, with a link to the request.\n\n### How to set up\n1. Create a free [Apify account](https://apify.com/?fpr=youssef) and connect it in the **Apify** node.\n2. Connect an OpenAI account in the **OpenAI model** node.\n3. Create an empty Google Sheet and paste its URL into **Set your profile**.\n4. Create a Telegram bot with @BotFather, connect it, and paste your chat id into **Set your profile**. To find your chat id, message @userinfobot on Telegram and it replies with it.\n5. Fill in `yourName`, `yourRole` and `yourCredentials`. The drafts are only as good as what you write here, so be specific: numbers, years, what you have actually built.\n\n### Requirements\n- Apify account (the free plan returns 25 requests per run and holds back the last 48 hours)\n- An OpenAI account, Google Sheets and a Telegram bot\n\n### How to customize\n- Raise `minScore` if you get too many weak matches, lower it if you get too few. The scorer is strict by design, so 55 to 65 is the useful band and 70+ will often return nothing.\n- `maxPitches` caps what one day can cost you in tokens and in replies to write.\n- Edit the system prompt in **Draft the pitch** to change the voice, the length or the sign-off. Drafts are a starting point, not an outbox: read each one before you send it.",
        "height": 1240,
        "width": 520,
        "color": 1
      },
      "id": "jp11a901-0000-4000-8000-000000000001",
      "name": "Sticky Note - About",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        -240
      ]
    },
    {
      "parameters": {
        "content": "### 1. Who you are\nThe scoring and the drafts are built entirely from this node, so it is worth ten minutes.\n\n`yourRole`: one line, concrete. \"Founder of a 6-person agency doing technical SEO for B2B SaaS\" beats \"marketing expert\".\n\n`yourCredentials`: what makes a journalist quote you rather than someone else. Numbers, years, clients, things you built or measured. The drafting prompt is told never to invent a detail, so if you leave this thin the pitches stay vague.\n\n`topics`: what to fetch, comma separated.\n\n`minScore`: 0 to 100. Start at 55 and watch the sheet for a week. The scorer is deliberately harsh: on a real run of 10 mixed requests against a well-written profile, the best match scored 60 and everything off-topic landed between 10 and 30. A threshold of 70 will usually give you nothing.\n\n`maxPitches`: the most drafts to write in one run.",
        "height": 760,
        "width": 520,
        "color": 7
      },
      "id": "jp11a901-0000-4000-8000-000000000002",
      "name": "Sticky Note - Step 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -200,
        -240
      ]
    },
    {
      "parameters": {
        "content": "### 2. Find what is open today\nThe Apify node runs the Journalist Request Finder across SourceBottle, Bluesky and Mastodon.\n\n`onlyNewRequests` asks the Actor to remember what it has already returned, which needs a paid Apify plan. **Remove Duplicates** repeats the guard inside n8n on `requestId`, so the free plan also gets a clean run instead of the whole board every morning.",
        "height": 760,
        "width": 520,
        "color": 7
      },
      "id": "jp11a901-0000-4000-8000-000000000003",
      "name": "Sticky Note - Step 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        360,
        -240
      ]
    },
    {
      "parameters": {
        "content": "### 3. Score it before you spend a draft on it\nThe LLM chain reads each request next to your profile and returns a score, a reason and the angle to lead with. It is told to be strict: 80 or above only when you could give a concrete, quotable answer from your own experience.\n\n**Every** request is written to Google Sheets with its score and reason, including the ones that fail. That is the tuning loop: read the rejects for a week, then move `minScore`.\n\nOnly the ones at or above `minScore` continue, and **Cap the daily pitches** stops a busy day from writing forty drafts.",
        "height": 760,
        "width": 1120,
        "color": 7
      },
      "id": "jp11a901-0000-4000-8000-000000000004",
      "name": "Sticky Note - Step 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        920,
        -240
      ]
    },
    {
      "parameters": {
        "content": "### 4. Write the reply, not a notification\nThe second chain writes the actual pitch in your voice: 90 words, opening on the most useful concrete thing you can say, one specific detail from your credentials, no flattery, no invented statistics.\n\nEach draft arrives as its own Telegram message with the request above it and a link to answer, so you read it, judge it, and copy it if it is right. That last judgement stays yours on purpose.\n\n**Read every draft before you send it.** The prompt forbids inventing numbers and tells the model that every figure must already appear in your credentials, but no prompt makes that guaranteed. A pitch goes to a journalist under your name, so check the facts in it are yours.",
        "height": 760,
        "width": 640,
        "color": 7
      },
      "id": "jp11a901-0000-4000-8000-000000000005",
      "name": "Sticky Note - Step 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        2100,
        -240
      ]
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "days",
              "triggerAtHour": 8
            }
          ]
        }
      },
      "id": "jp11a901-0000-4000-8000-000000000010",
      "name": "Every morning at 8am",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -120,
        120
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "jp11a901-0000-4000-8000-000000000101",
              "name": "yourName",
              "value": "PASTE_YOUR_NAME_HERE",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000102",
              "name": "yourRole",
              "value": "Founder of a small agency doing technical SEO for B2B SaaS companies",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000103",
              "name": "yourCredentials",
              "value": "Nine years in SEO, 40+ SaaS migrations, I run the monthly crawl data for 30 sites and have the before and after numbers for most of them.",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000104",
              "name": "topics",
              "value": "SaaS, marketing, small business",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000105",
              "name": "sources",
              "value": "sourcebottle, bluesky",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000106",
              "name": "maxRequests",
              "value": 100,
              "type": "number"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000107",
              "name": "minScore",
              "value": 55,
              "type": "number"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000108",
              "name": "maxPitches",
              "value": 5,
              "type": "number"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000109",
              "name": "googleSheetUrl",
              "value": "PASTE_YOUR_GOOGLE_SHEET_URL_HERE",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000110",
              "name": "telegramChatId",
              "value": "PASTE_YOUR_TELEGRAM_CHAT_ID_HERE",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "jp11a901-0000-4000-8000-000000000011",
      "name": "Set your profile",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        120,
        120
      ]
    },
    {
      "parameters": {
        "operation": "Run actor and get dataset",
        "actorSource": "store",
        "actorId": {
          "__rl": true,
          "value": "L6X82PO6c478CTxs7",
          "mode": "id"
        },
        "customBody": "={{ (() => {\n  const s = $json;\n  const list = v => String(v || '').split(',').map(x => x.trim()).filter(Boolean);\n  return JSON.stringify({\n    keywords: list(s.topics),\n    keywordsOnly: false,\n    onlyOpenDeadlines: true,\n    maxRequests: s.maxRequests,\n    sources: list(s.sources),\n    onlyNewRequests: true\n  });\n})() }}"
      },
      "id": "jp11a901-0000-4000-8000-000000000012",
      "name": "Find journalist requests",
      "type": "@apify/n8n-nodes-apify.apify",
      "typeVersion": 1,
      "position": [
        460,
        120
      ]
    },
    {
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "dedupeValue": "={{ $json.requestId }}",
        "options": {}
      },
      "id": "jp11a901-0000-4000-8000-000000000013",
      "name": "Skip requests already seen",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        720,
        120
      ]
    },
    {
      "parameters": {
        "text": "=EXPERT\nName: {{ $('Set your profile').first().json.yourName }}\nRole: {{ $('Set your profile').first().json.yourRole }}\nWhat makes them quotable: {{ $('Set your profile').first().json.yourCredentials }}\nTopics they claim: {{ $('Set your profile').first().json.topics }}\n\nJOURNALIST REQUEST\nHeadline: {{ $json.title }}\nAsked by: {{ $json.requesterName || $json.authorHandle || 'not stated' }} ({{ $json.requesterType || 'unknown role' }})\nSection: {{ $json.industry || 'not stated' }}\nDeadline: {{ $json.deadlineText || 'not stated' }}\nWhat they need: {{ $json.summary || $json.title }}",
        "messages": {
          "messageValues": [
            {
              "message": "You judge whether a journalist's source request is worth a pitch from one specific expert, and you are strict because a bad pitch costs the expert their reputation with that journalist.\n\nScore 0 to 100 on how well this expert can answer THIS request with first-hand authority. Score 80 or above only when the request sits squarely in their field and they could give a concrete, quotable answer from their own experience. Score below 40 when answering would mean repeating general knowledge anyone could give.\n\nGive one sentence saying why, naming the specific overlap or the specific gap. Then give the single strongest angle this expert should lead with: a concrete claim, number or story they own, not a topic area. If the score is below 40, the angle may be an empty string."
            }
          ]
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "id": "jp11a901-0000-4000-8000-000000000014",
      "name": "Score it against your expertise",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.5,
      "position": [
        980,
        120
      ]
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o-mini"
        },
        "options": {}
      },
      "id": "jp11a901-0000-4000-8000-000000000015",
      "name": "OpenAI model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.2,
      "position": [
        1500,
        460
      ]
    },
    {
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"score\": { \"type\": \"number\" },\n    \"reason\": { \"type\": \"string\" },\n    \"angle\": { \"type\": \"string\" }\n  },\n  \"required\": [\"score\", \"reason\", \"angle\"]\n}"
      },
      "id": "jp11a901-0000-4000-8000-000000000016",
      "name": "Score format",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.2,
      "position": [
        1760,
        460
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "jp11a901-0000-4000-8000-000000000201",
              "name": "Request",
              "value": "={{ $('Skip requests already seen').item.json.title }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000202",
              "name": "Score",
              "value": "={{ $json.output.score }}",
              "type": "number"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000203",
              "name": "Why",
              "value": "={{ $json.output.reason }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000204",
              "name": "Angle",
              "value": "={{ $json.output.angle }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000205",
              "name": "Source",
              "value": "={{ $('Skip requests already seen').item.json.source }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000206",
              "name": "Requester",
              "value": "={{ $('Skip requests already seen').item.json.requesterName || $('Skip requests already seen').item.json.authorHandle || '' }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000207",
              "name": "Requester type",
              "value": "={{ $('Skip requests already seen').item.json.requesterType || '' }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000208",
              "name": "Deadline",
              "value": "={{ $('Skip requests already seen').item.json.deadlineText || 'not stated' }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000209",
              "name": "Days left",
              "value": "={{ $('Skip requests already seen').item.json.daysLeft ?? '' }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000210",
              "name": "What they need",
              "value": "={{ (t => t.length > 500 ? t.slice(0, 500) + '...' : t)(String($('Skip requests already seen').item.json.summary || '').replace(/\\s+/g, ' ').trim()) }}",
              "type": "string"
            },
            {
              "id": "jp11a901-0000-4000-8000-000000000211",
              "name": "Respond URL",
              "value": "={{ $('Skip requests already seen').item.json.url }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "id": "jp11a901-0000-4000-8000-000000000017",
      "name": "Format the row",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1240,
        120
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "={{ $('Set your profile').first().json.googleSheetUrl }}",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "gid=0",
          "mode": "id"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "jp11a901-0000-4000-8000-000000000018",
      "name": "Save every scored request",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1500,
        20
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "jp11a901-0000-4000-8000-000000000301",
              "leftValue": "={{ Number($json.Score) >= Number($('Set your profile').first().json.minScore) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "jp11a901-0000-4000-8000-000000000019",
      "name": "Keep the strong matches",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.2,
      "position": [
        1500,
        240
      ]
    },
    {
      "parameters": {
        "maxItems": "={{ $('Set your profile').first().json.maxPitches }}"
      },
      "id": "jp11a901-0000-4000-8000-000000000020",
      "name": "Cap the daily pitches",
      "type": "n8n-nodes-base.limit",
      "typeVersion": 1,
      "position": [
        1760,
        240
      ]
    },
    {
      "parameters": {
        "text": "=EXPERT\nName: {{ $('Set your profile').first().json.yourName }}\nRole: {{ $('Set your profile').first().json.yourRole }}\nWhat makes them quotable: {{ $('Set your profile').first().json.yourCredentials }}\n\nJOURNALIST REQUEST\nHeadline: {{ $json.Request }}\nAsked by: {{ $json.Requester }}\nWhat they need: {{ $json['What they need'] }}\nDeadline: {{ $json.Deadline }}\n\nLEAD WITH THIS ANGLE\n{{ $json.Angle }}",
        "messages": {
          "messageValues": [
            {
              "message": "You write the reply an expert sends to a journalist's source request. It is the message the journalist reads first, so it has to earn the next 10 seconds.\n\nRules:\n- 90 words maximum, plain text, no subject line, no markdown, no bullet points.\n- Open with the most useful concrete thing the expert can say about the story. Never open with praise for the publication or a line about how interesting the request is.\n- Include one specific detail drawn from the expert's credentials. Every number you write must already appear, in those exact words, in the credentials above. If the credentials contain no number, write the pitch without one and describe what the expert can speak to instead. Do not estimate, do not write 'up to', do not round, do not illustrate with an example figure. Inventing a statistic, a client or a result is the one unrecoverable mistake here, because the expert may send this to a journalist as written.\n- Close with one short line offering availability and the format the journalist asked for.\n- Write in the expert's voice, first person, no exclamation marks, no buzzwords, no 'I would love to'."
            }
          ]
        },
        "promptType": "define"
      },
      "id": "jp11a901-0000-4000-8000-000000000021",
      "name": "Draft the pitch",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "typeVersion": 1.5,
      "position": [
        2180,
        240
      ]
    },
    {
      "parameters": {
        "chatId": "={{ $('Set your profile').first().json.telegramChatId }}",
        "text": "={{ (() => {\nconst esc = s => String(s ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');\nconst r = $('Format the row').item.json;\nconst draft = String($json.text || '').trim();\nconst who = [r['Requester'], r['Requester type']].filter(Boolean).join(' \\u00b7 ');\nconst when = r['Deadline'] === 'not stated' ? 'no deadline stated'\n  : r['Deadline'] + (r['Days left'] === '' ? '' : ' (' + r['Days left']\n    + (Number(r['Days left']) === 1 ? ' day left)' : ' days left)'));\nreturn '\\ud83d\\udcf0 <b>' + esc(String(r['Request']).slice(0, 110)) + '</b>\\n'\n  + esc(who) + (who ? ' \\u00b7 ' : '') + esc(when) + ' \\u00b7 match ' + r['Score'] + '/100\\n'\n  + (String(r['Angle'] || '').trim() ? '\\ud83c\\udfaf ' + esc(r['Angle']) + '\\n\\n' : '\\n')\n  + '<b>Draft reply</b>\\n<pre>' + esc(draft) + '</pre>\\n\\n'\n  + '\\u27a1 <a href=\"' + r['Respond URL'] + '\">Send it here</a>';\n})() }}",
        "additionalFields": {
          "appendAttribution": false,
          "parse_mode": "HTML",
          "disable_web_page_preview": true
        }
      },
      "id": "jp11a901-0000-4000-8000-000000000022",
      "name": "Send the draft to Telegram",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        2440,
        240
      ]
    }
  ],
  "connections": {
    "Every morning at 8am": {
      "main": [
        [
          {
            "node": "Set your profile",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set your profile": {
      "main": [
        [
          {
            "node": "Find journalist requests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find journalist requests": {
      "main": [
        [
          {
            "node": "Skip requests already seen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip requests already seen": {
      "main": [
        [
          {
            "node": "Score it against your expertise",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score it against your expertise": {
      "main": [
        [
          {
            "node": "Format the row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format the row": {
      "main": [
        [
          {
            "node": "Save every scored request",
            "type": "main",
            "index": 0
          },
          {
            "node": "Keep the strong matches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep the strong matches": {
      "main": [
        [
          {
            "node": "Cap the daily pitches",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cap the daily pitches": {
      "main": [
        [
          {
            "node": "Draft the pitch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft the pitch": {
      "main": [
        [
          {
            "node": "Send the draft to Telegram",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI model": {
      "ai_languageModel": [
        [
          {
            "node": "Score it against your expertise",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "Draft the pitch",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Score format": {
      "ai_outputParser": [
        [
          {
            "node": "Score it against your expertise",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  }
}
