The Pattern
Every morning at 6:30am, @Obi posts a formatted briefing to Discord. It contains today’s calendar events, recurring reminders, tasks due today, and a prioritized suggested task list — all pulled from different sources, assembled by the orchestrator, and delivered before Adam wakes up.
This is the pattern that makes an agent operationally useful. Not just answering questions, but proactively surfacing what matters.
Architecture
The morning brief uses a module pattern with three concerns separated:
- Modules — each data source independently produces a summary
- Orchestrator — collects summaries from all modules and assembles the brief
- Scheduler — determines when to trigger the orchestrator
Modules don’t know about each other. The orchestrator doesn’t know how each module gets its data. The scheduler doesn’t know what the brief contains. Each piece is independently testable and replaceable.
The Scheduler
A 1-minute interval timer. No cron dependency, no external scheduler — just a setInterval that checks the clock:
const BRIEF_HOUR = 6;
const BRIEF_MINUTE = 30;
let briefPostedToday = false;
setInterval(async () => {
const now = new Date();
// Reset at midnight
if (now.getHours() === 0 && now.getMinutes() === 0) {
briefPostedToday = false;
}
// Post brief at target time
if (
now.getHours() === BRIEF_HOUR &&
now.getMinutes() === BRIEF_MINUTE &&
!briefPostedToday
) {
try {
await postMorningBrief();
briefPostedToday = true;
} catch (err) {
console.error('Morning brief failed:', err);
}
}
}, 60_000);
Why not node-cron or system cron? Because the scheduler runs inside the agent process. It has access to all the modules, the Discord client, and the agent’s context. External cron would need to shell into the process or hit an HTTP endpoint — unnecessary complexity for a single daily trigger.
The Module Interface
Every module that contributes to the morning brief implements one method:
// Interface (conceptual — no runtime enforcement needed)
{
getMorningBriefSummary(): Promise<string | null>
}
Return a formatted string, or null if there’s nothing to report. The orchestrator skips nulls.
Calendar Module
Pulls today’s events from Google Calendar and merges them with recurring task reminders:
import { google } from 'googleapis';
async function getMorningBriefSummary() {
const auth = getAuthClient(); // OAuth2 with stored refresh token
const calendar = google.calendar({ version: 'v3', auth });
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
const res = await calendar.events.list({
calendarId: 'primary',
timeMin: startOfDay.toISOString(),
timeMax: endOfDay.toISOString(),
singleEvents: true,
orderBy: 'startTime',
});
const events = res.data.items || [];
if (events.length === 0) return null;
const lines = events.map(e => {
const start = e.start.dateTime
? new Date(e.start.dateTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
: 'All day';
return `- **${start}** — ${e.summary}`;
});
return `## Today's Schedule\n\n${lines.join('\n')}`;
}
Task Module
Pulls from Vikunja and splits into two sections: tasks due today (non-negotiable) and suggested tasks (agent-prioritized):
const VIKUNJA_URL = 'http://localhost:3456/api/v1';
const VIKUNJA_TOKEN = process.env.VIKUNJA_TOKEN;
async function getMorningBriefSummary() {
const dueTasks = await getTasksDueToday();
const suggested = await getSuggestedTasks();
const sections = [];
if (dueTasks.length > 0) {
const dueLines = dueTasks.map(t => `- ${t.title}`);
sections.push(`## Due Today\n\n${dueLines.join('\n')}`);
}
if (suggested.length > 0) {
const sugLines = suggested.map(t => {
const priority = t.priority >= 4 ? '🔴' : t.priority >= 2 ? '🟡' : '⚪';
return `- ${priority} ${t.title}`;
});
sections.push(`## Suggested Tasks\n\n${sugLines.join('\n')}`);
}
return sections.length > 0 ? sections.join('\n\n') : null;
}
async function getTasksDueToday() {
const today = new Date();
today.setHours(23, 59, 59, 999);
const res = await fetch(`${VIKUNJA_URL}/tasks/all?filter=due_date<"${today.toISOString()}"&filter_concat=and&filter=done=false`, {
headers: { Authorization: `Bearer ${VIKUNJA_TOKEN}` },
});
return res.json();
}
async function getSuggestedTasks() {
const res = await fetch(`${VIKUNJA_URL}/tasks/all?filter=done=false`, {
headers: { Authorization: `Bearer ${VIKUNJA_TOKEN}` },
});
const tasks = await res.json();
const urgent = tasks
.filter(t => t.priority >= 4)
.sort((a, b) => new Date(a.due_date || '9999') - new Date(b.due_date || '9999'))
.slice(0, 3);
const soon = tasks
.filter(t => t.priority >= 2 && t.priority < 4)
.filter(t => !urgent.includes(t))
.sort((a, b) => new Date(a.due_date || '9999') - new Date(b.due_date || '9999'))
.slice(0, 2);
const backlog = tasks
.filter(t => t.priority < 2)
.filter(t => !urgent.includes(t) && !soon.includes(t))
.slice(0, 1);
return [...urgent, ...soon, ...backlog]; // max 6
}
Three urgent, two soon, one backlog. The user gets a focused list — not a task dump.
The Orchestrator
Collects summaries from all modules and assembles the final message:
async function postMorningBrief() {
const modules = [calendarModule, taskModule];
const sections = await Promise.all(
modules.map(m => m.getMorningBriefSummary())
);
const body = sections.filter(Boolean).join('\n\n');
if (!body) return; // Nothing to report
const brief = `# ☀️ Morning Brief\n\n${body}`;
await sendFormattedMessage(briefChannel, brief);
}
The sendFormattedMessage helper handles Discord’s markdown quirks — blank lines before headings, 2000-char message splitting, and embed suppression:
async function sendFormattedMessage(channel, content) {
// Ensure blank line before headings (Discord requirement)
const fixed = content.replace(/([^\n])\n(#{1,3} )/g, '$1\n\n$2');
const chunks = splitMessage(fixed, 2000);
for (const chunk of chunks) {
const msg = await channel.send(chunk);
await msg.suppressEmbeds(true);
}
}
What the Brief Looks Like
# ☀️ Morning Brief
## Today's Schedule
- **9:00 AM** — Standup
- **1:00 PM** — Design review
- **3:30 PM** — Dentist
## Due Today
- Ship newsletter draft
- Review PR #47
## Suggested Tasks
- 🔴 Finish auth module refactor
- 🔴 Fix calendar sync timezone bug
- 🔴 Update deployment docs
- 🟡 Research embedding providers
- 🟡 Outline next blog post
- ⚪ Clean up old feature branches
Six suggested tasks, prioritized. Today’s hard deadlines separated from suggestions. Calendar events with times. All delivered to Discord before the first coffee.
Key Takeaway
The morning brief pattern is simple: modules produce summaries, the orchestrator assembles them, the scheduler triggers it. The power is in the separation. Adding a new data source — weather, GitHub notifications, RSS feeds — means implementing one getMorningBriefSummary() method. No changes to the orchestrator, no changes to the scheduler, no changes to existing modules. The brief grows by composition, not modification.