Building a Morning Brief: Multi-Source Agent Briefings

How to build a daily agent briefing that pulls from Google Calendar and Vikunja, formats it for Discord, and posts at 6:30am automatically.

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:

  1. Modules — each data source independently produces a summary
  2. Orchestrator — collects summaries from all modules and assembles the brief
  3. 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.