Nathanial Martin

Blog

Technologies I find interesting, and notes on things I'm teaching myself.

· 5 min read

Two n8n Agents I Built to Stop Starting My Day With Twelve Tabs

  • n8n
  • automation
  • llm
  • google-api
  • docker

Most mornings used to start the same way. Open Hacker News, open a few RSS feeds I never actually cleared, open Google Calendar, open Tasks, then forget which of those I had already looked at. None of it is hard work. It is just twenty minutes of context switching before I have written a line of code.

As an engineer at a large and technically complex company, it is constantly made clear that mitigating friction is one of the most valuable skills for in software. Same instinct here, smaller stakes. I gave myself a weekend and built two n8n agents: one that reads tech news and emails me summaries, one that emails me my calendar and top tasks at 6:30 AM.

Here is how both are put together, including the parts I got wrong first.

Why n8n

I wanted something self-hosted, and I already had Docker set up from my last round of container work. n8n runs as a single container with a Postgres volume behind it:

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n
    restart: always
    ports:
      - "5678:5678"
    environment:
      - GENERIC_TIMEZONE=America/New_York
      - TZ=America/New_York
    volumes:
      - n8n_data:/home/node/.n8n
volumes:
  n8n_data:

Set GENERIC_TIMEZONE. If you skip it, every schedule trigger fires on UTC and your 6:30 AM briefing shows up at 2:30 AM. I found that out the way you would expect.

Agent One: The Tech Digest

Five nodes, left to right.

Schedule Trigger runs at 7:00 AM daily. RSS Read pulls a feed. n8n's RSS node takes one URL per node, so I put five of them in parallel and merged the output rather than fighting a loop. Hacker News front page, Ars Technica, Github Blog, Y Combinator, and TechCrunch.

Code is where the filtering happens. RSS feeds hand you everything they have, not everything that is new, so without this step you get the same twelve articles every day forever.

// Keep only items published in the last 24 hours, drop duplicate links
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const seen = new Set();
const out = [];

for (const item of $input.all()) {
  const { title, link, isoDate, contentSnippet } = item.json;
  if (!link || seen.has(link)) continue;
  if (new Date(isoDate).getTime() < cutoff) continue;

  seen.add(link);
  out.push({
    json: {
      title,
      link,
      snippet: (contentSnippet || '').slice(0, 600),
    },
  });
}

return out.slice(0, 12);

The slice(0, 600) and the cap of twelve are cost control. Feeding full article bodies for thirty articles into a model every morning adds up fast, and the first sentences of a post carry most of what a summary needs anyway.

Basic LLM Chain does the summarizing. I used the AI Agent node first out of curiosity and then swapped it out. An agent gives the model the ability to call tools and decide what to do next, which is exactly what you do not want when the job is "read this text, write three sentences." The plain chain is cheaper, faster, and produces the same output.

My prompt, roughly:

You are summarizing tech news for a software engineer.
For the article below, write 2-3 sentences covering what
happened and why an engineer would care. No preamble, no
"this article discusses". Plain text only.

Title: {{ $json.title }}
Content: {{ $json.snippet }}

Telling it what not to write mattered more than telling it what to write. Without that second line, every summary opened with "This article discusses."

Gmail sends the result. One more Code node assembles the HTML so I get real links instead of a wall of text:

const rows = $input.all().map(({ json }) =>
  `<li><a href="${json.link}"><b>${json.title}</b></a><br>${json.summary}</li>`
).join('\n');

return [{ json: { html: `<ul>${rows}</ul>` } }];

In the Gmail node, set Email Type to HTML. Otherwise you get your markup rendered as literal text, which I also learned firsthand.

Agent Two: The Morning Briefing

This one has no LLM in it at all, and it is the one I actually read every day.

Schedule Trigger at 6:30 AM. Two branches off it.

Google Calendar → Get Many on my primary calendar, bounded to today:

After:  {{ $now.startOf('day').toISO() }}
Before: {{ $now.endOf('day').toISO() }}

n8n expressions run on Luxon, so $now gives you a full DateTime object with real methods on it. Turn on Single Events in the options or recurring meetings come back as one master entry instead of today's instance.

Google Tasks → Get Many on my default list. Here is the part worth knowing: the Google Tasks API has no priority field. What it has is position, a zero-padded string that reflects the manual order you dragged things into in the Tasks UI. If, like me, you keep your list sorted with the important thing on top, then sorting by position gives you priority order for free. Lexicographic sort works because of the zero padding.

const events = $('Google Calendar').all()
  .map(i => i.json)
  .sort((a, b) => (a.start.dateTime || a.start.date)
    .localeCompare(b.start.dateTime || b.start.date));

const tasks = $('Google Tasks').all()
  .map(i => i.json)
  .filter(t => t.status !== 'completed')
  .sort((a, b) => a.position.localeCompare(b.position))
  .slice(0, 5);

const fmt = t => new Date(t).toLocaleTimeString('en-US',
  { hour: 'numeric', minute: '2-digit', timeZone: 'America/New_York' });

const schedule = events.length
  ? events.map(e => `<li>${e.start.dateTime ? fmt(e.start.dateTime) : 'All day'} - ${e.summary}</li>`).join('')
  : '<li>Nothing scheduled</li>';

const todo = tasks.map(t => `<li>${t.title}</li>`).join('');

return [{ json: {
  html: `<h3>Today</h3><ul>${schedule}</ul><h3>Top 5</h3><ol>${todo}</ol>`
}}];

Both branches feed a Merge node set to Wait for All before the Code node runs. Skip that and the Code node fires on whichever branch finishes first and half your data is missing.

A Few Practical Notes

  • Google OAuth wants a redirect URI that matches your n8n instance exactly. If you are running on localhost:5678 and later move to a domain, you re-add every credential.
  • Test with Execute Workflow and a temporary manual trigger. Waiting until 6:30 AM to see whether your changes worked is a bad loop to be in.
  • Enable Error Workflow in workflow settings and point it at a two-node flow that emails you the failure. A silent 5:00 AM crash looks identical to a quiet day.
  • Keep the model on the smallest thing that produces readable output. Summarizing a dozen snippets is not a reasoning problem.

Neither of these is clever. The digest is a scheduler, an RSS parser, and a prompt. The briefing is two API calls and a sort. What made them worth building is that the total setup was an afternoon, and I have not opened Google Tasks in my browser since.

Next on the list is folding my GitHub notifications into the same 6:30 email, since that is the other tab I keep forgetting to close.

Browse all 3 posts
Email copied to clipboard