BuildLeaner the monday brief
part two of the AI chief of staff

The brief that turns up before you do.

Eight boxes in n8n read your week, your sent folder, your inbox and your task list, hand all of it to a chief of staff that is told never to invent a number, and email you the answer at 7am on Monday. You are asleep when it runs. It costs about two cents.

Monday Brief: 14 Sep 07:00
from n8n via Gmail
to you
built from 12 meetings, 340 messages, 9 list items

THE ONE THING

The lead magnet page has to be live before Friday. Nothing else on this list blocks a publish, and two things on it depend on that page existing.

SLIPPING

  • Shorts from the last video, moved three weeks running.
  • M., handover thread. You sent last on 3 Sep. 11 days.
  • Server logs looked at, not fixed. Second week.

SAY NO

Content sync, Monday 4pm, four people, no agenda, recurring. Cancel it. Send: "Killing the Monday sync, I will post the update in the channel instead."

WHAT I HAVE NOT TOLD YOU

Nothing here says what was agreed on last Thursday's call. If that changed the priority, this brief is wrong and I have no way to know. [uncertain]

An example, shaped like a real one. Yours reads your week, not this one.

Eight boxes. That is the whole thing.

Each box does one small job and hands its work to the next one. Nothing here is clever. It is a conveyor belt.

Boxes 3 and 4 are the interesting pair. One reads everything you sent, one reads everything that came back, and box 6 works out which conversations died. A conversation is dead when the last message in it is yours.

Five things to get first

Four of them you already have. Collect all five before you start and the build never stalls.

  1. An n8n accountThe cloud one, or an instance on your own server. Both work.
  2. A Google accountYou sign in once and it covers calendar, mail and sheets.
  3. An Anthropic API keyFrom platform.claude.com. This is prepaid credit, not a Claude subscription. Five dollars lasts a very long time at three cents a week.
  4. Your own email addressWhere the brief lands.
  5. A Google Sheet for your listOne tab. Cell A1 says task. One job per row under it. Add to it from your phone during the week.

The build, click by click

Free, ungated, all of it. If you would rather not click through 8 boxes, the importable file is at the bottom of the page.

Do this before box one: in n8n, three dots, Settings, Timezone. Set your own. Skip it and your 7am brief arrives at 11am and you will spend an hour blaming the trigger.

01 The alarm clock ›

Add a Schedule Trigger.

  • Trigger Interval: Weeks
  • Weeks Between Triggers: 1
  • Trigger on Weekdays: Monday
  • Trigger at Hour: 7am, Minute: 0

Rename it Monday 7am. Names matter here: the code in box 6 calls the other boxes by name.

02 Read the week ›

Add Google Calendar. Create the credential, sign in, allow. You do this once and the other Google boxes reuse it.

  • Resource Event, Operation Get Many
  • Pick your calendar. Return All: on.
  • Add Option twice: After and Before. Switch each to Expression and paste the two lines below.
{{ $now.startOf('week').toUTC().toISO() }}

{{ $now.startOf('week').plus({ days: 7 }).toUTC().toISO() }}

The first is the Monday of this week at midnight. The second is the Monday after. So it grabs exactly this week, every week, forever.

Keep the .toUTC(). Without it the date ends in +04:00, and a plus sign inside a web address means something else. Google answers 400 Bad Request and explains nothing.

03 Read what I sent ›

Add Gmail. Resource Message, Operation Get Many, Return All on.

  • Simplify: off. Off gives the raw message, and the raw shape is identical across n8n versions. On gives a tidier object that changes between versions and breaks the code in box 6.
  • Add Option, Search, and paste: in:sent newer_than:30d
  • Settings tab at the top of the node, Execute Once: on.

Execute Once is the checkbox nobody mentions. The calendar box just handed this one twelve meetings. Without it, this box downloads your entire sent folder twelve times.

04 Read what came back ›

Identical to box 3, with one change: the search is in:inbox newer_than:30d. Simplify off, Execute Once on.

Now you have two piles. Everything you sent, and everything that came back.

05 Read my list ›

Add Google Sheets, same credential. Resource Sheet Within Document, Operation Get Row(s). Pick the document and the tab. Execute Once on.

06 Glue it together ›

Add a Code box, mode Run Once for All Items, and paste this. You are not writing it, you are pasting it.

The only idea in here: a conversation is dead when the last message in it is yours. Note the newest reply in every thread, walk the sent folder, keep the ones nothing came back on. Everything else is tidying dates.

const MIN_DAYS_SILENT = 3;

const events = $('Get the week').all().map(i => i.json);
const sent   = $('Get what I sent').all().map(i => i.json);
const inbox  = $('Get what came back').all().map(i => i.json);
const rows   = $('Get my list').all().map(i => i.json);

const header = (msg, name) => {
  const list = (msg && msg.payload && msg.payload.headers) || [];
  const hit = list.find(h => String(h.name).toLowerCase() === name.toLowerCase());
  return hit ? hit.value : '';
};

const dayOf  = s => String(s || '').slice(0, 10);
const timeOf = s => (String(s || '').length > 10 ? String(s).slice(11, 16) : 'all day');

const calendar = events
  .filter(e => e.status !== 'cancelled')
  .map(e => {
    const start = e.start && (e.start.dateTime || e.start.date);
    const end   = e.end   && (e.end.dateTime   || e.end.date);
    const who   = (e.attendees || []).length;
    return {
      sort: String(start || ''),
      line: `${dayOf(start)}  ${timeOf(start)}-${timeOf(end)}  ${e.summary || '(no title)'}`
          + (who ? `  [${who} people]` : '  [solo]')
    };
  })
  .sort((a, b) => a.sort.localeCompare(b.sort))
  .map(e => e.line)
  .join('\n') || 'Nothing on the calendar this week.';

const lastReply = {};
for (const m of inbox) {
  const t = Number(m.internalDate || 0);
  if (!lastReply[m.threadId] || t > lastReply[m.threadId]) lastReply[m.threadId] = t;
}

sent.sort((a, b) => Number(b.internalDate || 0) - Number(a.internalDate || 0));

const seen = new Set();
const silent = [];
for (const m of sent) {
  if (seen.has(m.threadId)) continue;
  seen.add(m.threadId);
  const mine = Number(m.internalDate || 0);
  if ((lastReply[m.threadId] || 0) > mine) continue;
  const days = Math.floor((Date.now() - mine) / 86400000);
  if (days < MIN_DAYS_SILENT) continue;
  silent.push({
    days,
    line: `${days} days silent  |  ${header(m, 'To')}  |  ${header(m, 'Subject')}`
        + `  |  last sent ${dayOf(new Date(mine).toISOString())}`
  });
}
silent.sort((a, b) => b.days - a.days);

const threads = silent.length
  ? silent.map(s => s.line).join('\n')
  : 'No silent threads. Everything got an answer.';

const tasks = rows
  .map(r => r.task || r.Task || Object.values(r)[0])
  .filter(v => v && String(v).trim())
  .map(v => `- ${String(v).trim()}`)
  .join('\n') || 'The list is empty.';

const paste = [
  'MONDAY BRIEF. Here is my week.',
  '',
  'CALENDAR (next 7 days):',
  calendar,
  '',
  `OPEN THREADS (I sent last, nobody replied, ${MIN_DAYS_SILENT}+ days):`,
  threads,
  '',
  'MY LIST:',
  tasks
].join('\n');

return [{ json: { paste } }];
07 Ask the chief of staff ›

Add an HTTP Request box.

  • Method POST, URL https://api.anthropic.com/v1/messages
  • Authentication Generic Credential Type, then Header Auth. New credential: name x-api-key, value is your key.
  • Send Headers on, one header: anthropic-version = 2023-06-01
  • Send Body on, JSON, and paste the body below as an Expression.

The JSON.stringify at the end is the line that breaks everyone. It escapes every line break and apostrophe in your text. Without it, one quote mark in a meeting title kills the whole request.

{
  "model": "claude-sonnet-5",
  "max_tokens": 2000,
  "system": "You are my chief of staff. Not an assistant, not a cheerleader. Your job is to protect my attention and tell me the truth about my week.\n\nWHO YOU WORK FOR\n[Your name]. [What you do, in two lines.] My scoreboard is [the one or two numbers that actually matter]. Everything else is noise, including things that feel urgent.\n\nWHAT YOU CAN SEE\nOnly the text below. It was pulled automatically this morning from my calendar for the coming week, my sent and received mail from the last 30 days, and a list I keep in a spreadsheet. If something is not in there, you do not know it. Never guess what is in a meeting, a document or a message you were not shown.\n\nHOW YOU ANSWER\nShortest useful answer. No preamble. Lead with the thing I am most likely to get wrong this week. Names, numbers and dates, not adjectives. Rank by consequence, not by urgency. Loud is not important.\n\nHARD RULES\nNever invent a meeting, a person, a number, a deadline or a quote. If you are not sure, write [uncertain] next to the line. A marked gap is useful. A confident invention costs me money. If I am about to spend a day on something that does not move my scoreboard, say that first. Do not flatter me. If my week is badly built, open with that.\n\nFORMAT\nReturn clean HTML only. No markdown, no code fences, no html or body tags. Use h2 for the five headings, p for text, ul and li for lists, strong for the few things I must not miss. Under 400 words.\n\nUse exactly these five headings, in this order:\nTHE ONE THING\nFIVE BULLETS\nSLIPPING\nSAY NO\nWHAT I HAVE NOT TOLD YOU",
  "messages": [
    { "role": "user", "content": {{ JSON.stringify($json.paste) }} }
  ]
}

Fill the three bracketed lines in the charter with your own details. They are the whole difference between a generic answer and one about your week.

Never leave this node pinned. A pinned node does not call anything, does not error, and hands its test data down the chain. Every box goes green and the email arrives empty.

08 Send it to me ›

Add Gmail, Operation Send.

  • To: your own address
  • Subject, as an Expression: Monday Brief: {{ $now.toFormat('d LLL') }}
  • Email Type: HTML. Check it twice, the default is plain text.
  • Message, as an Expression: {{ $json.content.find(c => c.type === 'text').text }}
  • Add Option, Append n8n Attribution, off. Otherwise every brief ends with an advert.

Use find rather than content[0]. The reply is a list of blocks and the text block is not always first, so content[0].text works until the day it returns an empty email with no error.

Press Test workflow. Check your inbox. Then flip the workflow Active, top right, and close the tab.

Skip the clicking. Import the file.

The finished workflow, as an n8n file. Import it, click your accounts in, put your email in one field, and it runs. About ten minutes instead of forty.

One file, no course, no upsell. You also get Steal This System on Thursdays: one working system a week. Unsubscribe any time.

Where this breaks

Three limits. I still run it every week, I just know what it is.

It only knows what got pulled.Calendar, mail, list. A decision somebody made on a phone call does not exist to it, and it will never tell you it is missing one.
It is not judgment.It ranks your week against the scoreboard you wrote in the charter. Point it at the wrong scoreboard and it organises the wrong life, confidently, every Monday.
Silence is not always death.Newsletters and notifications look exactly like a thread nobody answered. Tighten the search when it starts annoying you: in:sent newer_than:30d -label:newsletters

Three things that broke on the real build

Google Calendar returns 400. The date is the problem and Google made the date. toISO() ends in +04:00, and that plus sign means something else inside a web address. Add .toUTC() before .toISO() so it ends in a Z. If you are on UTC you will never hit this one.

A syntax error on a line you did not write. Text expanders fire inside code editors. Select all, delete, confirm the box is empty, turn the expander off for that tab, then paste. Pasting inserts at the cursor, it does not replace.

Empty emails, every node green. Look for a purple strip on a node. It is pinned. A pinned node never runs, never errors, and hands its test data down the chain. Two pinned items means two empty emails.

And the usual two: the Gmail search is newer_than:30d, not 30days. And if the email is full of asterisks, that is markdown. Check the Gmail box is set to Email Type HTML, then tighten the FORMAT block in the charter.