Overview
Build a weekly digest system that delivers a summary of Quiver activity to the team via webhook — Slack, Discord, email, or any HTTP endpoint. The digest answers the question: "What happened in Quiver this week?" without anyone having to log in.
This is the team's heartbeat. It surfaces what shipped, what performed, what needs attention, and what changed — so the whole team stays aligned on marketing momentum even if they're not in the app every day.
What the digest contains
Every digest covers the past 7 days and includes these sections, in order:
1. What shipped
Artifacts that moved to live status in the past 7 days.
🚀 What shipped this week
• Cold Email Sequence v2 (cold_email) — Developer Outreach campaign
• Q2 Landing Page (landing_page) — Product Launch campaign
• April Newsletter (newsletter) — Content calendar
2. Performance highlights
Performance log entries recorded in the past 7 days with whatWorked or notable metrics.
📈 Performance this week
• ICP Outreach Email: 34% open rate, 12 replies — "Technical framing worked well"
• LinkedIn Thread: 2.4k impressions, 18 reposts
Only show entries that have at least one metric or a whatWorked note. Skip reminder-only entries.
3. Close the loop queue
Artifacts that are live but have no performance results logged yet, grouped by how overdue they are.
🔁 Needs results logged
• Developer FAQ Page — live 18 days ago (overdue)
• Cold Email Sequence v1 — live 9 days ago
• Product Hunt Post — live 3 days ago
Only include if queue is non-empty.
4. Context changes
Context version updates approved in the past 7 days.
📝 Context updated
• Positioning statement updated — "Tightened around structured output layer framing"
• ICP definition updated — "Narrowed to seed-Series A AI-native startups"
Only include if any updates happened.
5. Pending proposals
Count of AI-proposed context updates awaiting review.
⏳ 3 context update proposals waiting for review
→ {APP_URL}/context
Only include if count > 0.
6. Active campaigns snapshot
Current status of all active campaigns — artifact count and last activity date.
📋 Active campaigns
• Developer Outreach — 4 artifacts, last activity 2 days ago
• Q2 Product Launch — 7 artifacts, last activity today
7. AI-generated summary (optional)
A 2-3 sentence natural language summary of the week generated by Claude. Uses the same data above. Example:
"Strong week for outreach — the cold email sequence is live and the ICP targeting work is paying off with above-average open rates. Three artifacts are overdue for results logging, including the FAQ page from 18 days ago. One context update is waiting for review."
Only generated if DIGEST_AI_SUMMARY=true in env. Off by default to keep the digest lightweight and free of AI costs for teams that don't want it.
Delivery mechanisms
Support three delivery targets. Each is optional and independently configurable. A team can use all three, one, or none.
Slack webhook
Posts to a Slack channel via incoming webhook URL. Format: structured Slack Block Kit message with sections matching the digest sections above.
Generic webhook (HTTP POST)
Posts JSON payload to any URL — Discord (via Discord webhook), Zapier, Make, email via a webhook service, or any custom endpoint. Content-Type: application/json. Format defined below.
Email (via Resend)
Sends HTML email to one or more addresses. Uses RESEND_API_KEY if configured. Clean HTML template, no external CSS frameworks.
Schedule
Default: Monday mornings at 8am in the team's configured timezone. Covers the previous 7 days (Monday to Sunday).
Configurable per delivery target:
- Day of week: any day
- Time: any hour (0-23)
- Timezone: IANA timezone string (e.g.
America/Chicago)
- Frequency: weekly (only option in v1)
New DB table
⚠️ Approval gate: Show schema addition and wait for approval before running migration.
Add to prisma/schema.prisma as part of migration 0004_digest:
model DigestConfig {
id String @id @default(uuid())
enabled Boolean @default(false)
// Slack
slackEnabled Boolean @default(false)
slackWebhookUrl String?
// Generic webhook
webhookEnabled Boolean @default(false)
webhookUrl String?
webhookSecret String? // optional HMAC secret for request signing
// Email via Resend
emailEnabled Boolean @default(false)
emailRecipients String[] // array of email addresses
// Schedule
dayOfWeek Int @default(1) // 0=Sunday, 1=Monday ... 6=Saturday
hourOfDay Int @default(8) // 0-23 in timezone
timezone String @default("America/New_York")
// AI summary
aiSummaryEnabled Boolean @default(false)
// Meta
lastSentAt DateTime?
createdBy String
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@map("digest_config")
}
One row per workspace. Created on first save of digest settings.
New lib files
lib/digest/data.ts
Fetches all data needed to build the digest. No AI calls here — pure DB queries. Exportable and testable independently.
/**
* Digest Data Layer — lib/digest/data.ts
*
* What it does: Fetches all data needed to assemble a weekly digest.
* What it reads from: All Quiver DB tables via Prisma.
* What it produces: A typed DigestData object covering the past 7 days.
* Edge cases:
* - Empty sections return empty arrays (never null).
* - Reminder entries are filtered out of performance logs.
* - Only published/live artifacts appear in shipped list.
*/
export interface DigestData {
period: { from: Date; to: Date }
shippedArtifacts: Array<{
id: string
title: string
type: string
campaignName: string
}>
performanceHighlights: Array<{
artifactTitle: string | null
campaignName: string
metrics: Record<string, unknown> | null
whatWorked: string | null
recordedAt: Date
}>
closeTheLoopQueue: Array<{
artifactId: string
artifactTitle: string
campaignName: string
wentLiveAt: Date
daysOverdue: number
}>
contextChanges: Array<{
version: number
changeSummary: string | null
updateSource: string | null
createdAt: Date
}>
pendingProposalsCount: number
activeCampaigns: Array<{
id: string
name: string
artifactCount: number
lastActivityAt: Date | null
}>
}
export async function getDigestData(from: Date, to: Date): Promise<DigestData>
Implementation notes:
shippedArtifacts: artifacts where status = 'live' and updatedAt is within the period
performanceHighlights: PerformanceLog entries where recordedAt is within period, qualitativeNotes does NOT start with REMINDER_PREFIX, and at least one of metrics or whatWorked is non-null
closeTheLoopQueue: call getReminders() from lib/db/artifacts.ts — it already handles this query. Compute daysOverdue as Math.max(0, daysSince(reminder.recordedAt))
contextChanges: ContextVersion rows where createdAt is within period and updateSource is not null
pendingProposalsCount: count of PerformanceLog rows where contextUpdateStatus = 'pending'
activeCampaigns: campaigns where status = 'active', include _count.artifacts and most recent artifact updatedAt as lastActivityAt
lib/digest/format.ts
Converts DigestData into formatted payloads for each delivery target. No DB calls, no AI calls — pure formatting.
// Plain text version (used in email plain-text fallback and generic webhook)
export function formatDigestText(data: DigestData, appUrl: string): string
// Slack Block Kit JSON
export function formatDigestSlack(data: DigestData, appUrl: string): object
// HTML for email
export function formatDigestHtml(data: DigestData, appUrl: string): string
// JSON payload for generic webhook
export function formatDigestJson(data: DigestData, appUrl: string): object
Slack Block Kit format:
- Header block: "Quiver weekly digest — [date range]"
- Divider
- One section block per non-empty digest section
- Each section uses mrkdwn for formatting
- Footer: link back to Quiver dashboard
- Empty sections are omitted entirely — a quiet week should produce a short digest, not empty sections
Generic webhook JSON shape:
{
"period": { "from": "ISO", "to": "ISO" },
"shipped": [...],
"performance": [...],
"closeTheLoop": [...],
"contextChanges": [...],
"pendingProposals": 3,
"activeCampaigns": [...],
"summary": "AI summary string or null",
"digestUrl": "https://yourapp.com/dashboard"
}
HTML email format:
- Clean, minimal HTML. Inline styles only — no
<style> tags (email client compatibility)
- White background, dark text, clear section headings
- Each section is a
<table> with a heading row and data rows
- Footer with unsubscribe note: "To stop receiving this digest, update your settings at {appUrl}/settings"
lib/digest/send.ts
Handles delivery to each configured target.
/**
* Digest Delivery — lib/digest/send.ts
*
* What it does: Sends a formatted digest to all configured delivery targets.
* What it reads from: DigestConfig (from DB), formatted payloads from format.ts.
* What it produces: Delivery results per target.
* Edge cases:
* - One target failing does not block other targets.
* - Webhook signing uses HMAC-SHA256 if webhookSecret is set.
* - Resend not available: email target silently skipped with error logged.
*/
export interface SendResult {
slack: { sent: boolean; error?: string }
webhook: { sent: boolean; error?: string }
email: { sent: boolean; error?: string }
}
export async function sendDigest(
config: DigestConfig,
data: DigestData,
appUrl: string
): Promise<SendResult>
Implementation notes:
- Slack:
fetch(config.slackWebhookUrl, { method: 'POST', body: JSON.stringify(slackPayload) })
- Webhook: POST JSON payload. If
webhookSecret is set, add X-Quiver-Signature header: HMAC-SHA256(JSON.stringify(payload), secret) encoded as hex
- Email: Use
resend.emails.send() with HTML body and text fallback. from: should be Quiver Digest <digest@{configured_domain}> — admin sets the from domain in settings
- Each target wrapped in try/catch — failure is logged to stderr, result records the error, execution continues
lib/digest/ai-summary.ts
Optional AI summary generation. Only called if config.aiSummaryEnabled = true.
export async function generateDigestSummary(data: DigestData): Promise<string | null>
Makes a single non-streaming call to sendMessage() from lib/ai/client.ts. Prompt:
You are writing a brief weekly summary for a marketing team.
In 2-3 sentences, summarize this week's marketing activity based on the data below.
Be specific about what happened. Mention the most important signal.
Do not use bullet points. Write in plain prose.
[JSON.stringify(data, null, 2)]
On error or timeout: return null. Never throw — a failed summary should not block digest delivery.
API routes
app/api/digest/config/route.ts
GET — fetch current digest config (admin only)
- Returns config row or
{ configured: false } if none exists
- Masks
webhookSecret — return "***" if set, omit entirely if not
- Never returns
slackWebhookUrl or webhookUrl in full — return last 6 characters only for display: "...abc123"
POST — create or update digest config (admin only)
- Body: all
DigestConfig fields except id, createdAt, updatedAt, lastSentAt
- Upsert: update if exists, create if not
- Validate:
dayOfWeek 0-6, hourOfDay 0-23, valid IANA timezone string, valid URLs for webhook and Slack
- Returns saved config (with masked secrets)
app/api/digest/send/route.ts
POST — trigger digest immediately (admin only, manual send for testing)
- No body required
- Fetches config, builds digest data for past 7 days, sends to all enabled targets
- Returns
{ sent: true, results: SendResult, preview: DigestData }
- Rate limited: max 3 manual sends per hour per user
app/api/digest/preview/route.ts
GET — preview digest data without sending (admin only)
- Returns
DigestData for the past 7 days as JSON
- Used by the settings UI to show a live preview before enabling
app/api/cron/digest/route.ts
Cron endpoint called by Vercel cron scheduler.
// vercel.json cron entry:
// { "path": "/api/cron/digest", "schedule": "0 * * * *" } // runs every hour
The cron runs every hour and checks whether it's time to send based on DigestConfig.dayOfWeek, hourOfDay, and timezone:
- Fetch digest config where
enabled = true
- For each config, convert current UTC time to the configured timezone
- If current day-of-week and hour match the config, and
lastSentAt is not within the past 23 hours (prevents double-sends), send the digest
- Update
lastSentAt after successful send
Auth: validate CRON_SECRET header matches process.env.CRON_SECRET. Return 401 if missing or wrong.
Update vercel.json to add the cron entry.
Settings UI
Add a Digest section to app/(app)/settings/page.tsx (admin only), below the existing settings sections.
Layout
Two subsections: Configuration and Preview.
Configuration subsection:
- Master enable/disable toggle: "Send weekly digest"
- Schedule row: day of week select (Mon–Sun), time select (12am–11pm in 1-hour increments), timezone select (list of major IANA timezones — not all 500+, just the ~40 commonly used ones)
- AI summary toggle: "Include AI-generated summary" with note: "Uses one AI call per digest. Adds ~$0.01 per week."
Delivery targets — three collapsible sections, each with an enable toggle:
Slack:
- Webhook URL input (full URL on entry, masked on display after save)
- Instructions: "Create an Incoming Webhook in your Slack workspace. [How to set up →]"
Webhook:
- URL input (masked after save)
- Secret input (optional, masked after save): "Used to sign requests with HMAC-SHA256 via X-Quiver-Signature header"
Email:
- Recipients input: comma-separated email addresses
- From domain input: the domain to send from (requires Resend DNS setup)
- Note: "Requires RESEND_API_KEY in your environment."
Action buttons:
- "Save settings"
- "Send test digest now" — calls
POST /api/digest/send, shows results inline
- "Preview digest data" — calls
GET /api/digest/preview, shows the DigestData as a formatted preview in the UI
Preview subsection:
Shows a rendered preview of what the digest will look like based on current data. Renders the HTML email format inline. Updates when "Preview digest data" is clicked. Shows a note: "This is how your digest will look based on the past 7 days."
Environment variables
Add to .env.example:
# Digest (optional)
RESEND_API_KEY= # required for email delivery
CRON_SECRET= # required — validates cron endpoint requests
CRON_SECRET should already exist from issue #22 (settings). Verify before adding.
MCP tool — add to mcp/tools/workspace.ts
get_digest_preview
- Description: Get a preview of what this week's Quiver digest would contain — what shipped, performance highlights, close-the-loop queue, context changes, and active campaigns.
- Input: none
- Output: Full
DigestData object
- Implementation: Call
getDigestData() from lib/digest/data.ts with a 7-day window
send_digest_now
- Description: Trigger the weekly digest to be sent immediately to all configured delivery targets.
- Input: none
- Output:
{ sent: boolean, results: SendResult }
- Implementation: Fetch config, call
sendDigest() from lib/digest/send.ts
types/index.ts additions
export interface DigestData {
period: { from: Date; to: Date }
shippedArtifacts: Array<{ id: string; title: string; type: string; campaignName: string }>
performanceHighlights: Array<{ artifactTitle: string | null; campaignName: string; metrics: Record<string, unknown> | null; whatWorked: string | null; recordedAt: Date }>
closeTheLoopQueue: Array<{ artifactId: string; artifactTitle: string; campaignName: string; wentLiveAt: Date; daysOverdue: number }>
contextChanges: Array<{ version: number; changeSummary: string | null; updateSource: string | null; createdAt: Date }>
pendingProposalsCount: number
activeCampaigns: Array<{ id: string; name: string; artifactCount: number; lastActivityAt: Date | null }>
}
Acceptance criteria
Database
Lib layer
API routes
Settings UI
MCP
Types
Dependencies
Notes for the builder
lib/digest/ has zero Next.js imports. These files must be importable from the MCP server. Use lib/ai/client.ts for AI calls — it has no Next.js dependencies. Use Prisma directly for DB calls.
- The cron runs hourly, not weekly. It checks each hour whether it's time to send based on the config. This is the standard Vercel cron pattern for timezone-aware scheduling — a daily or weekly cron would miss timezone edge cases.
lastSentAt prevents double-sends. The 23-hour guard on lastSentAt means even if the cron fires twice in the same hour, the digest only sends once per configured cycle.
- Secret masking is non-negotiable. Webhook URLs and the Slack webhook URL contain credentials. Never return them in full from the API after initial save. Last 6 chars only.
- Empty digest is valid. A team with no activity in the past 7 days should receive a short digest saying so, not an error. The formatter handles this: if all sections are empty, output "Quiet week — nothing to report."
- Resend is optional. If
RESEND_API_KEY is not set, email delivery silently skips with an error in SendResult. Don't throw.
- Test it with a real Slack webhook. The Slack Block Kit format has quirks — test with
curl against a real Slack incoming webhook before marking complete.
Overview
Build a weekly digest system that delivers a summary of Quiver activity to the team via webhook — Slack, Discord, email, or any HTTP endpoint. The digest answers the question: "What happened in Quiver this week?" without anyone having to log in.
This is the team's heartbeat. It surfaces what shipped, what performed, what needs attention, and what changed — so the whole team stays aligned on marketing momentum even if they're not in the app every day.
What the digest contains
Every digest covers the past 7 days and includes these sections, in order:
1. What shipped
Artifacts that moved to
livestatus in the past 7 days.2. Performance highlights
Performance log entries recorded in the past 7 days with
whatWorkedor notable metrics.Only show entries that have at least one metric or a
whatWorkednote. Skip reminder-only entries.3. Close the loop queue
Artifacts that are live but have no performance results logged yet, grouped by how overdue they are.
Only include if queue is non-empty.
4. Context changes
Context version updates approved in the past 7 days.
Only include if any updates happened.
5. Pending proposals
Count of AI-proposed context updates awaiting review.
Only include if count > 0.
6. Active campaigns snapshot
Current status of all active campaigns — artifact count and last activity date.
7. AI-generated summary (optional)
A 2-3 sentence natural language summary of the week generated by Claude. Uses the same data above. Example:
Only generated if
DIGEST_AI_SUMMARY=truein env. Off by default to keep the digest lightweight and free of AI costs for teams that don't want it.Delivery mechanisms
Support three delivery targets. Each is optional and independently configurable. A team can use all three, one, or none.
Slack webhook
Posts to a Slack channel via incoming webhook URL. Format: structured Slack Block Kit message with sections matching the digest sections above.
Generic webhook (HTTP POST)
Posts JSON payload to any URL — Discord (via Discord webhook), Zapier, Make, email via a webhook service, or any custom endpoint. Content-Type:
application/json. Format defined below.Email (via Resend)
Sends HTML email to one or more addresses. Uses
RESEND_API_KEYif configured. Clean HTML template, no external CSS frameworks.Schedule
Default: Monday mornings at 8am in the team's configured timezone. Covers the previous 7 days (Monday to Sunday).
Configurable per delivery target:
America/Chicago)New DB table
Add to
prisma/schema.prismaas part of migration0004_digest:One row per workspace. Created on first save of digest settings.
New lib files
lib/digest/data.tsFetches all data needed to build the digest. No AI calls here — pure DB queries. Exportable and testable independently.
Implementation notes:
shippedArtifacts: artifacts wherestatus = 'live'andupdatedAtis within the periodperformanceHighlights:PerformanceLogentries whererecordedAtis within period,qualitativeNotesdoes NOT start withREMINDER_PREFIX, and at least one ofmetricsorwhatWorkedis non-nullcloseTheLoopQueue: callgetReminders()fromlib/db/artifacts.ts— it already handles this query. ComputedaysOverdueasMath.max(0, daysSince(reminder.recordedAt))contextChanges:ContextVersionrows wherecreatedAtis within period andupdateSourceis not nullpendingProposalsCount: count ofPerformanceLogrows wherecontextUpdateStatus = 'pending'activeCampaigns: campaigns wherestatus = 'active', include_count.artifactsand most recent artifactupdatedAtaslastActivityAtlib/digest/format.tsConverts
DigestDatainto formatted payloads for each delivery target. No DB calls, no AI calls — pure formatting.Slack Block Kit format:
Generic webhook JSON shape:
{ "period": { "from": "ISO", "to": "ISO" }, "shipped": [...], "performance": [...], "closeTheLoop": [...], "contextChanges": [...], "pendingProposals": 3, "activeCampaigns": [...], "summary": "AI summary string or null", "digestUrl": "https://yourapp.com/dashboard" }HTML email format:
<style>tags (email client compatibility)<table>with a heading row and data rowslib/digest/send.tsHandles delivery to each configured target.
Implementation notes:
fetch(config.slackWebhookUrl, { method: 'POST', body: JSON.stringify(slackPayload) })webhookSecretis set, addX-Quiver-Signatureheader:HMAC-SHA256(JSON.stringify(payload), secret)encoded as hexresend.emails.send()with HTML body and text fallback.from:should beQuiver Digest <digest@{configured_domain}>— admin sets the from domain in settingslib/digest/ai-summary.tsOptional AI summary generation. Only called if
config.aiSummaryEnabled = true.Makes a single non-streaming call to
sendMessage()fromlib/ai/client.ts. Prompt:On error or timeout: return
null. Never throw — a failed summary should not block digest delivery.API routes
app/api/digest/config/route.tsGET — fetch current digest config (admin only)
{ configured: false }if none existswebhookSecret— return"***"if set, omit entirely if notslackWebhookUrlorwebhookUrlin full — return last 6 characters only for display:"...abc123"POST — create or update digest config (admin only)
DigestConfigfields except id, createdAt, updatedAt, lastSentAtdayOfWeek0-6,hourOfDay0-23, valid IANA timezone string, valid URLs for webhook and Slackapp/api/digest/send/route.tsPOST — trigger digest immediately (admin only, manual send for testing)
{ sent: true, results: SendResult, preview: DigestData }app/api/digest/preview/route.tsGET — preview digest data without sending (admin only)
DigestDatafor the past 7 days as JSONapp/api/cron/digest/route.tsCron endpoint called by Vercel cron scheduler.
The cron runs every hour and checks whether it's time to send based on
DigestConfig.dayOfWeek,hourOfDay, andtimezone:enabled = truelastSentAtis not within the past 23 hours (prevents double-sends), send the digestlastSentAtafter successful sendAuth: validate
CRON_SECRETheader matchesprocess.env.CRON_SECRET. Return 401 if missing or wrong.Update
vercel.jsonto add the cron entry.Settings UI
Add a Digest section to
app/(app)/settings/page.tsx(admin only), below the existing settings sections.Layout
Two subsections: Configuration and Preview.
Configuration subsection:
Delivery targets — three collapsible sections, each with an enable toggle:
Slack:
Webhook:
Email:
Action buttons:
POST /api/digest/send, shows results inlineGET /api/digest/preview, shows theDigestDataas a formatted preview in the UIPreview subsection:
Shows a rendered preview of what the digest will look like based on current data. Renders the HTML email format inline. Updates when "Preview digest data" is clicked. Shows a note: "This is how your digest will look based on the past 7 days."
Environment variables
Add to
.env.example:CRON_SECRETshould already exist from issue #22 (settings). Verify before adding.MCP tool — add to
mcp/tools/workspace.tsget_digest_previewDigestDataobjectgetDigestData()fromlib/digest/data.tswith a 7-day windowsend_digest_now{ sent: boolean, results: SendResult }sendDigest()fromlib/digest/send.tstypes/index.tsadditionsAcceptance criteria
Database
DigestConfigmodel added to schema, migration0004_digestruns cleanLib layer
lib/digest/data.ts—getDigestData()returns correct data for all sections, empty arrays for empty sectionslib/digest/data.ts— reminder entries filtered from performance highlights usingREMINDER_PREFIXlib/digest/format.ts— all four formatters implemented (text, Slack, HTML, JSON)lib/digest/format.ts— empty sections omitted from formatted outputlib/digest/format.ts— HTML uses inline styles only, no<style>tagslib/digest/send.ts— one target failing does not block otherslib/digest/send.ts— webhook signing with HMAC-SHA256 when secret is setlib/digest/ai-summary.ts— returns null on error, never throwsAPI routes
GET /api/digest/configmasks secrets, admin onlyPOST /api/digest/configvalidates all fields, upserts correctlyPOST /api/digest/sendtriggers immediate delivery, rate limitedGET /api/digest/previewreturnsDigestDatawithout sendingPOST /api/cron/digestvalidatesCRON_SECRET, checks timing logic, updateslastSentAtvercel.json(runs hourly)Settings UI
MCP
get_digest_previewtool added tomcp/tools/workspace.tssend_digest_nowtool added tomcp/tools/workspace.tsmcp/index.tsTypes
DigestDatainterface added totypes/index.tsDependencies
DigestConfigmodelgetDigestData()queries performance logsshippedArtifactsqueries by status,getReminders()fromlib/db/artifacts.tscontextChangesqueriescontext_versionsNotes for the builder
lib/digest/has zero Next.js imports. These files must be importable from the MCP server. Uselib/ai/client.tsfor AI calls — it has no Next.js dependencies. Use Prisma directly for DB calls.lastSentAtprevents double-sends. The 23-hour guard onlastSentAtmeans even if the cron fires twice in the same hour, the digest only sends once per configured cycle.RESEND_API_KEYis not set, email delivery silently skips with an error inSendResult. Don't throw.curlagainst a real Slack incoming webhook before marking complete.