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.
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.
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."
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.
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.
Four of them you already have. Collect all five before you start and the build never stalls.
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.
Add a Schedule Trigger.
Rename it Monday 7am. Names matter here: the code in box 6 calls the other boxes by name.
Add Google Calendar. Create the credential, sign in, allow. You do this once and the other Google boxes reuse it.
{{ $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.
Add Gmail. Resource Message, Operation Get Many, Return All 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.
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.
Add Google Sheets, same credential. Resource Sheet Within Document, Operation Get Row(s). Pick the document and the tab. Execute Once on.
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 } }];
Add an HTTP Request box.
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.
Add Gmail, Operation Send.
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.
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.
Three limits. I still run it every week, I just know what it is.
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.