obi-jam pattern intermediate

Building a Morning Brief: Multi-Source Agent Briefings

The morning brief is the first thing that makes an agent feel like a teammate instead of a chatbot. It shows up before you do, with everything you need to start the day.

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.

FAQ

How do I build a morning brief for my AI agent?

Create a module pattern where each data source (calendar, tasks, reminders) exposes a getMorningBriefSummary() method. Run a 1-minute interval timer that checks the current time. At your target time (e.g., 6:30am), call getMorningBriefSummary() on every module, join the results, and post to your messaging channel. Use a flag to prevent duplicate posts.

How do I prevent duplicate morning brief posts?

Track a briefPostedToday boolean flag. Set it to true after posting. Reset it at midnight (check for hour 0, minute 0 in your interval). This handles restarts too — if the process restarts after posting, the flag resets and the brief won't re-post until the next day's target time because the time check won't match again.

How should I prioritize tasks in an agent morning brief?

Pull 3 urgent (priority >= 4), 2 soon (priority 2-3), and 1 backlog (priority < 2) for a maximum of 6 suggested tasks. This gives the user a focused, actionable list without overwhelming them. Separate 'due today' tasks from 'suggested' tasks — due today is non-negotiable, suggested is the agent's recommendation.

How do I add a new data source to my agent's morning brief?

Implement a getMorningBriefSummary() method on your new module that returns a formatted string (or null if there's nothing to report). Register the module with the orchestrator. That's it — the orchestrator calls every registered module and assembles the results. Modules don't know about each other.