Morning Brief Workflow — Design
Date: 2026-04-12
Status: Draft — awaiting review
Touches: @cambium/cortex, deployments/obadiah/bridge/start-rhythms.ts
Purpose
When morning.pulse fires at 06:00 Berlin, a receptor catches it and composes a real morning brief for Joshua — then posts it to Discord #general. This is the original promise from when we designed Rhythms: “the morning brief actually arrives at 6am.”
The brief currently just logs. After this work, it speaks.
How it differs from BL-008 v2 (persona chat replies)
| Concern | BL-008 v2 (persona reply) | Morning brief |
|---|---|---|
| Trigger | Reactive — Discord message arrives | Scheduled — morning.pulse rhythm fires |
| Format | Conversational, single-turn | Structured briefing with sections |
| Persona | Domain-specific (Arnold, MacKenzie, …) | Chief of staff (Obadiah itself) |
| Sources | Message content + memory recall | Substrate + memory + git log + daily file + yesterday’s routing history |
| Reply surface | Threaded reply on the triggering message | New top-level message in #general |
They share the same LLM calling infrastructure (Anthropic via @cambium/providers) but the brief composer is a distinct module, not a reuse of PersonaInvoker.
Architecture
03:00 Berlin memory.consolidate rhythm fires
→ consolidation receptor processes yesterday's daily file
→ epigenetic marks written to Postgres
(this already works — shipped in Memory v1)
06:00 Berlin morning.pulse rhythm fires
→ morning brief receptor catches it
→ composer gathers sources:
1. Substrate excerpts (identity, priorities, current projects)
2. Memory recall for joshua.* with horizon=1d (yesterday's marks)
3. Routing history: recall joshua.discord.routed.* horizon=1d
4. Git log: last 24h commits across known project dirs
5. Today's daily file (if any early entries exist)
→ one Sonnet LLM call composes the brief
→ DiscordClient posts to #general as a top-level message
→ emit morning.brief_delivered signal
The consolidation rhythm at 03:00 is important context: by the time the brief fires at 06:00, yesterday’s daily file has already been digested into marks. The brief reads from those marks, not from the raw daily file — which means the brief has a clean, structured view of yesterday.
Brief format
The LLM receives a structured prompt with all the gathered context and produces a brief in this shape:
**Good morning, Joshua.** Here's your brief for Saturday, April 12.
**Yesterday's highlights:**
- [from memory marks: what happened, what was observed, routing decisions made]
**Active projects:**
- [from substrate: current-projects.md excerpts]
**Git activity (last 24h):**
- [from git log: commit summaries across known repos]
**Today's priorities:**
- [from substrate: goals-and-priorities.md + any relevant marks]
**Pending seeds:**
- [from memory_seeds WHERE state='pending': proposals waiting for your review]
☕ Have a good day.
The exact format is part of the prompt template, not hardcoded in the output. The LLM has creative latitude within the structure — it’s not a mechanical template fill.
Source gathering
1. Substrate excerpts
const substrate = await recall({
about: 'joshua',
layers: ['substrate'],
workspace_id,
});
// substrate.substrate contains: identity, priorities, projects, workflow, boundaries
// These rarely change — they're the "who you are" context for the brief
2. Yesterday’s memory marks
const yesterday = await recall({
about: 'joshua',
horizon: '1d',
layers: ['marks'],
workspace_id,
});
// yesterday.marks contains: observations from consolidation + routing decisions
3. Routing history
const routing = await recall({
about: 'joshua.discord.routed',
horizon: '1d',
layers: ['marks'],
workspace_id,
});
// routing.marks contains: every Discord message routed to a persona yesterday
4. Git log
// Shell out to git for last 24h across known project directories
const gitLog = await execGitLog([
'/Users/joshua/Projects/Unicorn.Land/cambium',
'/Users/joshua/Projects/Obadiah',
// other project dirs from spore or substrate
]);
The git log fetcher is a small utility that runs git log --oneline --since='24 hours ago' --all in each directory and concatenates the results. Project dirs can be hardcoded for v1 (from spore or substrate) — a future version reads them from a spore.paths.projects[] field.
5. Pending seeds
const seeds = await store.findSeedsByWorkspace(workspace_id, 'pending');
// Show any seeds the memory system planted that Joshua should review
6. Today’s daily file
const today = daily.readDay(new Date());
// If Joshua wrote anything before 6am, include it
// Usually empty — the daily file fills during the day
The prompt template
You are Obadiah, Joshua's chief of staff AI. You are composing his morning briefing.
Your voice: direct, warm, efficient. No fluff. Lead with what matters. Use humor
sparingly. Joshua appreciates brevity — if a section has nothing notable, skip it
entirely rather than saying "nothing to report."
## Context about Joshua
${substrate excerpts — identity, priorities, projects, workflow}
## Yesterday's observations
${yesterday's marks — observations, strengths, evidence summaries}
## Messages routed to personas yesterday
${routing marks — which personas matched which messages}
## Git activity (last 24h)
${git log summaries}
## Pending memory seeds
${seeds awaiting review, with their proposed changes}
## Today's daily file (early entries)
${today's daily content, if any}
Compose a morning briefing. Use the format:
- "Good morning, Joshua" opening with the date
- Yesterday's highlights (2-5 bullets, skip if nothing notable)
- Active projects (only mention projects with recent activity or pending work)
- Git activity (summarize themes, don't list every commit)
- Today's priorities (inferred from substrate + yesterday's activity)
- Pending seeds (if any exist — list each with a one-line summary)
- Brief, warm closing
Be yourself. Joshua likes you direct and honest.
What Ships in v1
MorningBriefComposermodule atpackages/cortex/src/morning-brief-composer.ts— gathers all sources, composes prompt, calls Sonnet, returns the brief text- Git log utility at
packages/cortex/src/git-log-fetcher.ts— runsgit login configured directories, returns structured summaries - Morning brief receptor — binds to
morning.pulse, calls the composer, posts to Discord, emitsmorning.brief_delivered - Wiring in start-rhythms.ts — register the receptor, pass spore-derived config (project dirs, Discord channel)
- Integration test — mock LLM + mock DiscordClient; verify the composer is called with the right sources and the mock reply is posted
- Graceful no-op without ANTHROPIC_API_KEY — brief receptor logs a warning and skips composition if no LLM is available
Dependencies
- BL-008 v2 must ship first — the morning brief uses the same Anthropic provider registration, the same DiscordClient.replyInThread (or
.postMessagefor top-level), and the same graceful-no-op pattern - Memory v1 — already shipped;
recall()is the primary data source - Rhythms v1 — already shipped;
morning.pulsefires the trigger
Deferred
| Item | Why | Trigger |
|---|---|---|
| Calendar integration | Gmail/Calendar is federated through Claude Code; the daemon can’t read them directly | When the daemon gets its own OAuth organ — or when Joshua writes calendar context into his daily file before bed |
| Email summary | Same federated-via-Claude-Code model | Same as calendar |
| Project dir auto-discovery | v1 hardcodes project dirs from spore; future reads them from substrate or a spore field | When Joshua adds or removes projects frequently |
| Brief delivery confirmation | v1 posts and logs; future could react-emoji for “read” confirmation | When Joshua wants to track whether he actually read the brief |
Non-Goals
- Interactive follow-up. The brief is a one-shot post. If Joshua replies, that’s a normal Discord message that gets routed through the persona router (BL-008 v2). The brief doesn’t maintain a conversation.
- Multi-channel delivery. v1 posts to Discord #general only. Email/Telegram/SMS delivery is out of scope.
- Customizable brief format. The format is in the prompt template. Users who want a different format edit the prompt. There’s no GUI format editor.
Success Criteria
- At 06:00 Berlin (or whenever
morning.pulsefires), a real brief appears in Discord #general as a top-level message - The brief includes: yesterday’s marks summary, git activity summary, active projects (from substrate), and pending seeds (if any)
- A
morning.brief_deliveredsignal is emitted after posting - Without
ANTHROPIC_API_KEY, the receptor logs a warning and skips — no crash, no empty post - Integration test passes with mock LLM + mock DiscordClient
- The brief reads naturally — it’s Obadiah’s voice, not a mechanical template fill