diff --git a/__tests__/api/weekly-summary.test.ts b/__tests__/api/weekly-summary.test.ts
new file mode 100644
index 00000000..f97050a2
--- /dev/null
+++ b/__tests__/api/weekly-summary.test.ts
@@ -0,0 +1,133 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import { POST } from "@/app/api/admin/send-weekly-summary/route"
+import { registerAgent, resetAgentRegistryForTests } from "@/lib/agent-registry"
+import { publishSystemEvent, resetPublishedEventsForTests } from "@/lib/events/system-events"
+import { renderWeeklySummaryEmail, type AgentWeeklySummary } from "@/lib/email/weekly-summary"
+
+const sendMock = vi.fn()
+
+vi.mock("resend", () => ({
+ Resend: vi.fn().mockImplementation(function ResendMock() {
+ return { emails: { send: sendMock } }
+ }),
+}))
+
+const manifest = (agentId: string, email: string, emailOptOut = false) => ({
+ agentId,
+ email,
+ emailOptOut,
+ model: "claude-haiku-4-5",
+ district: "data-center",
+ capabilities: ["quests"],
+ x402: { accepts: true },
+ status: "active",
+ endpoint: `https://example.com/${agentId}`,
+})
+
+beforeEach(() => {
+ resetAgentRegistryForTests()
+ resetPublishedEventsForTests()
+ sendMock.mockReset()
+ sendMock.mockResolvedValue({ data: { id: "sandbox-email" }, error: null })
+ process.env.MOLTBOT_GATEWAY_TOKEN = "admin-token"
+ process.env.RESEND_API_KEY = "re_test_sandbox"
+})
+
+describe("POST /api/admin/send-weekly-summary", () => {
+ it("sends weekly summaries to registered agent emails and respects opt-out", async () => {
+ registerAgent(manifest("agent-send", "Send@Example.com"))
+ registerAgent(manifest("agent-opt-out", "optout@example.com", true))
+
+ publishSystemEvent({
+ type: "quest.completed",
+ agentId: "agent-send",
+ questId: "quest-1",
+ quest: { title: "Map the nebula" },
+ occurredAt: "2026-06-24T12:00:00.000Z",
+ })
+ publishSystemEvent({
+ type: "agent.xp",
+ agentId: "agent-send",
+ xp: 75,
+ totalXp: 75,
+ level: 1,
+ occurredAt: "2026-06-24T12:01:00.000Z",
+ })
+ publishSystemEvent({
+ type: "badge.unlocked",
+ agentId: "agent-send",
+ badge: { id: "first-quest", name: "First Quest Completed", rarity: "common" },
+ occurredAt: "2026-06-24T12:02:00.000Z",
+ })
+
+ const response = await POST(new Request("http://localhost/api/admin/send-weekly-summary", {
+ method: "POST",
+ headers: { authorization: "Bearer admin-token", "x-open-stellar-now": "2026-06-28T12:00:00.000Z" },
+ }))
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(body.ok).toBe(true)
+ expect(sendMock).toHaveBeenCalledTimes(1)
+ expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({
+ to: "send@example.com",
+ subject: "Open Stellar weekly summary for agent-send",
+ text: expect.stringContaining("Map the nebula"),
+ html: expect.stringContaining("First Quest Completed"),
+ }))
+ expect(body.results).toEqual([
+ expect.objectContaining({ agentId: "agent-send", to: "send@example.com", skipped: false }),
+ expect.objectContaining({ agentId: "agent-opt-out", to: "optout@example.com", skipped: true, reason: "agent opted out of email" }),
+ ])
+ })
+
+ it("requires admin authorization", async () => {
+ const response = await POST(new Request("http://localhost/api/admin/send-weekly-summary", { method: "POST" }))
+ expect(response.status).toBe(401)
+ expect(sendMock).not.toHaveBeenCalled()
+ })
+})
+
+describe("weekly summary template", () => {
+ const baseSummary: AgentWeeklySummary = {
+ agentId: "agent-template",
+ agentName: "agent-template",
+ questsCompleted: [],
+ xpEarned: 0,
+ level: 1,
+ previousLevel: null,
+ leveledUp: false,
+ leaderboardRank: null,
+ previousLeaderboardRank: null,
+ weekStartsAt: "2026-06-21T00:00:00.000Z",
+ weekEndsAt: "2026-06-28T00:00:00.000Z",
+ }
+
+ it("renders the 0-quest scenario", () => {
+ const email = renderWeeklySummaryEmail(baseSummary)
+ expect(email.text).toContain("Quests completed: 0")
+ expect(email.text).toContain("No quests completed this week")
+ expect(email.html).toContain("Quests completed: 0")
+ })
+
+ it("renders the 5+-quest scenario", () => {
+ const email = renderWeeklySummaryEmail({
+ ...baseSummary,
+ questsCompleted: Array.from({ length: 6 }, (_, index) => ({
+ questId: `quest-${index}`,
+ title: `Quest ${index + 1}`,
+ completedAt: "2026-06-24T12:00:00.000Z",
+ })),
+ xpEarned: 360,
+ level: 2,
+ leveledUp: true,
+ leaderboardRank: 1,
+ previousLeaderboardRank: 3,
+ })
+ expect(email.text).toContain("Quests completed: 6")
+ expect(email.text).toContain("6. Quest 6")
+ expect(email.text).toContain("New level: 2")
+ expect(email.html).toContain("
Quest 6")
+ })
+})
diff --git a/app/api/admin/send-weekly-summary/route.ts b/app/api/admin/send-weekly-summary/route.ts
new file mode 100644
index 00000000..5dc4a78d
--- /dev/null
+++ b/app/api/admin/send-weekly-summary/route.ts
@@ -0,0 +1,43 @@
+import { NextResponse } from "next/server"
+
+import { listRegisteredAgents } from "@/lib/agent-registry"
+import { isAuthorized } from "@/lib/auth"
+import { sendRawEmail } from "@/lib/email/resend"
+import { buildAgentWeeklySummary, renderWeeklySummaryEmail } from "@/lib/email/weekly-summary"
+
+export const dynamic = "force-dynamic"
+
+export async function POST(req: Request) {
+ if (!isAuthorized(req)) {
+ return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 })
+ }
+
+ const nowHeader = req.headers.get("x-open-stellar-now")
+ const now = nowHeader ? new Date(nowHeader) : new Date()
+ if (Number.isNaN(now.getTime())) {
+ return NextResponse.json({ ok: false, error: "Invalid x-open-stellar-now header" }, { status: 400 })
+ }
+
+ const agents = listRegisteredAgents()
+ const allAgentIds = agents.map((agent) => agent.agentId)
+ const results = []
+
+ for (const agent of agents) {
+ if (!agent.email) {
+ results.push({ agentId: agent.agentId, skipped: true, reason: "agent has no email on file" })
+ continue
+ }
+
+ if (agent.emailOptOut) {
+ results.push({ agentId: agent.agentId, to: agent.email, skipped: true, reason: "agent opted out of email" })
+ continue
+ }
+
+ const summary = buildAgentWeeklySummary(agent.agentId, agent.agentId, allAgentIds, now)
+ const email = renderWeeklySummaryEmail(summary)
+ const result = await sendRawEmail({ to: agent.email, ...email })
+ results.push({ agentId: agent.agentId, to: agent.email, ...result })
+ }
+
+ return NextResponse.json({ ok: true, results }, { headers: { "Cache-Control": "no-store" } })
+}
diff --git a/lib/agent-registry.ts b/lib/agent-registry.ts
index 71ce95b2..424e4c9d 100644
--- a/lib/agent-registry.ts
+++ b/lib/agent-registry.ts
@@ -14,6 +14,8 @@ export interface SkillRegistration {
export interface AgentCapabilityManifest {
agentId: string
+ email?: string
+ emailOptOut?: boolean
model: string
district: DistrictId
capabilities: string[]
@@ -70,6 +72,15 @@ function normalizeString(value: unknown, field: string): string {
return value.trim()
}
+function normalizeOptionalEmail(value: unknown): string | undefined {
+ if (value === undefined) return undefined
+ const email = normalizeString(value, "email").toLowerCase()
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ throw new Error("email must be a valid email address")
+ }
+ return email
+}
+
function normalizeDistrict(value: unknown): DistrictId {
const district = normalizeString(value, "district")
if (!DISTRICTS.includes(district as DistrictId)) {
@@ -221,11 +232,14 @@ export function registerAgent(input: unknown): AgentCapabilityManifest {
const now = new Date().toISOString()
const agentId = normalizeString(input.agentId, "agentId")
+ const email = normalizeOptionalEmail(input.email)
const dependencies = normalizeDependencies(input.dependencies)
validateDependencies(agentId, dependencies ?? [])
const agent: AgentCapabilityManifest = {
agentId,
+ ...(email === undefined ? {} : { email }),
+ ...(input.emailOptOut === undefined ? {} : { emailOptOut: input.emailOptOut === true }),
model: normalizeString(input.model, "model"),
district: normalizeDistrict(input.district),
capabilities: normalizeCapabilities(input.capabilities),
@@ -276,4 +290,4 @@ export function deregisterAgent(agentId: string): AgentCapabilityManifest | null
export function resetAgentRegistryForTests(): void {
registry.agents.clear()
-}
\ No newline at end of file
+}
diff --git a/lib/email/resend.ts b/lib/email/resend.ts
index a3bff429..c8910e35 100644
--- a/lib/email/resend.ts
+++ b/lib/email/resend.ts
@@ -105,6 +105,24 @@ async function sendReactEmail(subject: string, to: string, react: React.ReactNod
return { skipped: false, data }
}
+export async function sendRawEmail(options: { to: string; subject: string; text: string; html: string }) {
+ if (!process.env.RESEND_API_KEY) {
+ return { skipped: true, reason: "RESEND_API_KEY is not configured" }
+ }
+
+ const resend = new Resend(process.env.RESEND_API_KEY)
+ const { data, error } = await resend.emails.send({
+ from: fromAddress(),
+ to: options.to,
+ subject: options.subject,
+ text: options.text,
+ html: options.html,
+ })
+
+ if (error) throw new Error(error.message)
+ return { skipped: false, data }
+}
+
function BadgeUnlockedEmail({ agentName, badgeName, badgeRarity, unsubscribeUrl }: BadgeUnlockedEmailInput) {
return React.createElement(
"div",
diff --git a/lib/email/weekly-summary.ts b/lib/email/weekly-summary.ts
new file mode 100644
index 00000000..cfc68387
--- /dev/null
+++ b/lib/email/weekly-summary.ts
@@ -0,0 +1,136 @@
+import { getBadgeCatalogEntry, type BadgeRarity } from "@/lib/gamification/badge-catalog"
+import { getAgentXP } from "@/lib/gamification/xp"
+import { listPublishedEvents } from "@/lib/events/system-events"
+
+export interface WeeklyQuestSummaryItem {
+ questId: string
+ title: string
+ completedAt: string
+}
+
+export interface WeeklyBadgeSummaryItem {
+ id: string
+ name: string
+ rarity?: BadgeRarity
+ xpValue: number
+ earnedAt: string
+}
+
+export interface AgentWeeklySummary {
+ agentId: string
+ agentName: string
+ questsCompleted: WeeklyQuestSummaryItem[]
+ xpEarned: number
+ level: number
+ previousLevel: number | null
+ leveledUp: boolean
+ leaderboardRank: number | null
+ previousLeaderboardRank: number | null
+ topBadge?: WeeklyBadgeSummaryItem
+ weekStartsAt: string
+ weekEndsAt: string
+}
+
+export interface WeeklySummaryTemplate {
+ subject: string
+ text: string
+ html: string
+}
+
+const RARITY_WEIGHT: Record = { common: 1, uncommon: 2, rare: 3, epic: 4, legendary: 5 }
+
+export function getPreviousSundayWindow(now = new Date()): { start: Date; end: Date } {
+ const end = new Date(now)
+ end.setUTCHours(0, 0, 0, 0)
+ const daysSinceSunday = end.getUTCDay()
+ end.setUTCDate(end.getUTCDate() - daysSinceSunday)
+ const start = new Date(end)
+ start.setUTCDate(start.getUTCDate() - 7)
+ return { start, end }
+}
+
+function escapeHtml(value: string): string {
+ return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'")
+}
+
+function rankText(current: number | null, previous: number | null): string {
+ if (current === null) return "Not ranked this week"
+ if (previous === null) return `#${current} (new on leaderboard)`
+ const delta = previous - current
+ if (delta > 0) return `#${current} (up ${delta} from last week)`
+ if (delta < 0) return `#${current} (down ${Math.abs(delta)} from last week)`
+ return `#${current} (unchanged from last week)`
+}
+
+function eventTimeMs(event: { occurredAt?: string }): number {
+ return new Date(event.occurredAt ?? 0).getTime()
+}
+
+function inWindow(event: { occurredAt?: string }, startMs: number, endMs: number): boolean {
+ const ms = eventTimeMs(event)
+ return ms >= startMs && ms < endMs
+}
+
+function questTitle(quest: unknown, questId: string): string {
+ if (typeof quest === "object" && quest !== null && "title" in quest && typeof quest.title === "string") {
+ return quest.title
+ }
+ return questId || "Quest completed"
+}
+
+function computeRank(agentIds: string[], startMs: number, endMs: number): Map {
+ const counts = new Map(agentIds.map((agentId) => [agentId, 0]))
+ for (const event of listPublishedEvents()) {
+ if (event.type !== "quest.completed" || !event.agentId || !inWindow(event, startMs, endMs)) continue
+ counts.set(event.agentId, (counts.get(event.agentId) ?? 0) + 1)
+ }
+ const sorted = [...counts.entries()].filter(([, count]) => count > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ const ranks = new Map()
+ let currentRank = 1
+ for (let i = 0; i < sorted.length; i += 1) {
+ if (i > 0 && sorted[i]![1] < sorted[i - 1]![1]) currentRank = i + 1
+ ranks.set(sorted[i]![0], currentRank)
+ }
+ return ranks
+}
+
+export function buildAgentWeeklySummary(agentId: string, agentName: string, allAgentIds: string[], now = new Date()): AgentWeeklySummary {
+ const { start, end } = getPreviousSundayWindow(now)
+ const startMs = start.getTime()
+ const endMs = end.getTime()
+ const previousStartMs = startMs - 7 * 24 * 60 * 60 * 1000
+ const events = listPublishedEvents().filter((event) => event.agentId === agentId && inWindow(event, startMs, endMs))
+ const xpEvents = events.filter((event) => event.type === "agent.xp")
+ const xpEarned = xpEvents.reduce((sum, event) => sum + event.xp, 0)
+ const level = getAgentXP(agentId).level
+ const previousLevel = xpEvents.length > 0 ? xpEvents[0]!.level - (xpEvents[0]!.xp > 0 && xpEvents[0]!.level > 1 ? 1 : 0) : null
+ const badges = events.filter((event) => event.type === "badge.unlocked").map((event) => {
+ const catalog = getBadgeCatalogEntry(event.badge.id)
+ return { id: event.badge.id, name: event.badge.name, rarity: event.badge.rarity, xpValue: catalog?.xpValue ?? 0, earnedAt: event.occurredAt }
+ }).sort((a, b) => (RARITY_WEIGHT[b.rarity ?? "common"] - RARITY_WEIGHT[a.rarity ?? "common"]) || b.xpValue - a.xpValue)
+ return {
+ agentId,
+ agentName,
+ questsCompleted: events.filter((event) => event.type === "quest.completed").map((event) => ({ questId: event.questId ?? "", title: questTitle(event.quest, event.questId ?? ""), completedAt: event.occurredAt })),
+ xpEarned,
+ level,
+ previousLevel,
+ leveledUp: xpEvents.some((event) => event.level > level - 1),
+ leaderboardRank: computeRank(allAgentIds, startMs, endMs).get(agentId) ?? null,
+ previousLeaderboardRank: computeRank(allAgentIds, previousStartMs, startMs).get(agentId) ?? null,
+ topBadge: badges[0],
+ weekStartsAt: start.toISOString(),
+ weekEndsAt: end.toISOString(),
+ }
+}
+
+export function renderWeeklySummaryEmail(summary: AgentWeeklySummary): WeeklySummaryTemplate {
+ const questLines = summary.questsCompleted.length === 0 ? ["No quests completed this week. Your next quest is waiting."] : summary.questsCompleted.map((quest, index) => `${index + 1}. ${quest.title}`)
+ const levelLine = summary.leveledUp ? `New level: ${summary.level}` : `Current level: ${summary.level}`
+ const badgeLine = summary.topBadge ? `${summary.topBadge.name}${summary.topBadge.rarity ? ` (${summary.topBadge.rarity})` : ""}` : "No badges earned this week"
+ const subject = `Open Stellar weekly summary for ${summary.agentName}`
+ const text = [`Open Stellar weekly summary`, `Agent: ${summary.agentName}`, `Week: ${summary.weekStartsAt.slice(0, 10)} to ${summary.weekEndsAt.slice(0, 10)}`, "", `Quests completed: ${summary.questsCompleted.length}`, ...questLines, "", `XP earned: ${summary.xpEarned}`, levelLine, `Leaderboard rank: ${rankText(summary.leaderboardRank, summary.previousLeaderboardRank)}`, `Top badge: ${badgeLine}`].join("\n")
+ const htmlQuests = summary.questsCompleted.length === 0 ? "No quests completed this week. Your next quest is waiting.
" : `${summary.questsCompleted.map((quest) => `- ${escapeHtml(quest.title)}
`).join("")}
`
+ const html = `Open Stellar weekly summary
Agent: ${escapeHtml(summary.agentName)}
Week: ${summary.weekStartsAt.slice(0, 10)} to ${summary.weekEndsAt.slice(0, 10)}
Quests completed: ${summary.questsCompleted.length}
${htmlQuests}
XP earned: ${summary.xpEarned}
${summary.leveledUp ? "New" : "Current"} level: ${summary.level}
Leaderboard rank: ${escapeHtml(rankText(summary.leaderboardRank, summary.previousLeaderboardRank))}
Top badge: ${escapeHtml(badgeLine)}
`
+ return { subject, text, html }
+}
diff --git a/lib/events/system-events.ts b/lib/events/system-events.ts
index 7274025d..0ec983a7 100644
--- a/lib/events/system-events.ts
+++ b/lib/events/system-events.ts
@@ -57,6 +57,7 @@ type EventListener = (event: PublishedSystemEvent) => void
interface EventBusState {
listeners: Set
sequence: number
+ events: PublishedSystemEvent[]
}
const globalState = globalThis as typeof globalThis & {
@@ -66,6 +67,7 @@ const globalState = globalThis as typeof globalThis & {
const eventBus: EventBusState = globalState.__openStellarEventBus__ ?? {
listeners: new Set(),
sequence: 0,
+ events: [],
}
if (!globalState.__openStellarEventBus__) {
@@ -94,12 +96,22 @@ export function eventMatchesAgent(event: PublishedSystemEvent, agentId?: string)
export function publishSystemEvent(event: SystemEvent): PublishedSystemEvent {
const published = ensurePublishedEvent(event)
+ eventBus.events.push(published)
for (const listener of eventBus.listeners) {
listener(published)
}
return published
}
+export function listPublishedEvents(): PublishedSystemEvent[] {
+ return [...eventBus.events]
+}
+
+export function resetPublishedEventsForTests(): void {
+ eventBus.events.length = 0
+ eventBus.sequence = 0
+}
+
export function subscribeToSystemEvents(listener: EventListener) {
eventBus.listeners.add(listener)
return () => {