appscript.dev
Automation Intermediate Gmail

Digest daily news into a personal briefing

Summarise headlines on a schedule — Northwind morning briefing for Awadesh.

Published Nov 19, 2025

Keeping half an eye on industry news is part of the job at Northwind, but opening five sites every morning is a chore that quietly gets skipped. By the time anyone catches up, the interesting stories are buried under a week of noise. A short, themed briefing in the inbox solves it — the news comes to you, already grouped and trimmed to what matters.

This script pulls headlines from a list of RSS feeds, hands them to Claude to group by theme and condense to a bullet or two each, and emails the result. Point it at whatever feeds you follow, schedule it before you start work, and the briefing is waiting when you sit down.

What you’ll need

  • A list of RSS or Atom feed URLs you want to follow.
  • An Anthropic API key saved as ANTHROPIC_API_KEY in Script Properties — see Store API keys and secrets securely.
  • Gmail on the account running the script — GmailApp sends from it directly.

The script

// The RSS / Atom feeds to pull headlines from.
const FEEDS = [
  'https://news.example/rss',
  'https://techblog.example/rss',
];

// How many headlines to take from each feed. Skips the first <title>
// (the channel name) and keeps the next handful of real stories.
const HEADLINES_PER_FEED = 5;

// Where the finished briefing lands.
const BRIEFING_RECIPIENT = '[email protected]';

/**
 * Fetches headlines from each feed, asks Claude to group them by
 * theme, and emails the briefing.
 */
function morningBriefing() {
  // 1. Collect headlines from every feed into one list.
  const titles = [];
  for (const feedUrl of FEEDS) {
    const xml = UrlFetchApp.fetch(feedUrl, { muteHttpExceptions: true })
      .getContentText();
    // Pull <title> tags, drop the first (the feed name), keep a few.
    const feedTitles = [...xml.matchAll(/<title[^>]*>([^<]+)<\/title>/g)]
      .map((m) => m[1])
      .slice(1, HEADLINES_PER_FEED + 1);
    titles.push(...feedTitles);
  }

  // 2. Nothing fetched — skip the API call and the email.
  if (!titles.length) {
    Logger.log('No headlines fetched — nothing to brief.');
    return;
  }

  // 3. Ask Claude to organise the headlines into themed bullets.
  const prompt =
    'Northwind morning briefing. Group these headlines by theme; ' +
    'for each theme give 1-2 bullets.\n\n' + titles.join('\n');
  const summary = callClaude(prompt, 'claude-sonnet-4-6', 1000);

  // 4. Send the briefing to the inbox.
  GmailApp.sendEmail(BRIEFING_RECIPIENT, 'Morning briefing', summary);
  Logger.log('Briefing sent with ' + titles.length + ' headlines.');
}

/**
 * Minimal Anthropic API call. The key lives in Script Properties — it
 * is never pasted into the code.
 */
function callClaude(prompt, model = 'claude-haiku-4-5-20251001', maxTokens = 400) {
  const key = PropertiesService.getScriptProperties()
    .getProperty('ANTHROPIC_API_KEY');
  const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
    method: 'post',
    contentType: 'application/json',
    headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
    payload: JSON.stringify({
      model,
      max_tokens: maxTokens,
      messages: [{ role: 'user', content: prompt }],
    }),
    muteHttpExceptions: true,
  });
  return JSON.parse(res.getContentText()).content[0].text.trim();
}

How it works

  1. morningBriefing loops over the FEEDS list and fetches each one’s raw XML.
  2. A regular expression pulls every <title> tag out of the feed. The first title in an RSS feed is the channel name itself, so slice drops it and keeps the next HEADLINES_PER_FEED real stories.
  3. If no headlines came back at all — every feed down, or a network blip — the script logs that and stops, so you never get an empty email.
  4. The headlines are joined into one list and sent to Claude Sonnet with an instruction to cluster them by theme and condense each theme to a bullet or two. Sonnet is worth it here: grouping needs a little reasoning.
  5. GmailApp.sendEmail delivers the finished briefing to BRIEFING_RECIPIENT.

Example run

Suppose the feeds return headlines like:

  • “Regulator opens inquiry into ad-targeting practices”
  • “New open-source model beats benchmarks at a fraction of the cost”
  • “Major retailer reports record online sales for the quarter”
  • “Privacy bill clears committee vote”

The emailed briefing groups them:

Regulation & privacy

  • A regulator has opened an inquiry into ad-targeting; a related privacy bill has cleared committee.

AI

  • A new open-source model is matching benchmark leaders at much lower cost.

Retail

  • A major retailer posted record quarterly online sales.

Four scattered headlines become a three-section briefing you can read in under a minute.

Trigger it

This is a scheduled job — set it to run before your working day starts:

  1. In the Apps Script editor, open Triggers (the clock icon).
  2. Click Add Trigger.
  3. Choose morningBriefing, event source Time-driven, Day timer, and a slot such as 6am–7am.
  4. Save. The briefing now arrives every morning without you touching it.

Watch out for

  • Feed formats vary. The regex grabs every <title> element, which works for most RSS feeds but can pick up stray titles in Atom feeds. If a feed looks wrong, inspect its XML and tighten the pattern for that source.
  • The first <title> is the channel name. The slice(1, ...) drops it — if a feed nests its items differently, that assumption can clip a real story.
  • Headlines only, no article bodies. The briefing summarises titles, so it reflects what editors chose to headline, not the full story. That is usually enough for a morning scan.
  • A dead feed fails quietly. muteHttpExceptions keeps one broken URL from killing the run, but it also means you will not be told a feed went stale — check the logs occasionally.

Related