Phase 3: Cron Jobs
Depends on: Phase 1 (Bot Core — specifically
ChannelAdapter.sendMessage()andmessaging_userstable)
Can run in parallel with: Phase 2 (Agent + Tools)
Blocks: Phase 4 (Cleanup)
Goal
Port the three GoClaw cron jobs to node-cron running inside the API process. Each job queries existing service functions and sends results through the ChannelAdapter interface — automatically supporting any channel a user is paired on.
Current GoClaw Cron Jobs
| Name | Schedule | GoClaw Agent | What it does |
|---|---|---|---|
morning-briefing | 9 AM daily | briefing-agent | Fetches daily summary, composes personalized message per client |
approval-reminders | 10 AM daily | liaison-agent | Finds posts in pending_review >24h, sends gentle reminder |
weekly-summary | Monday 9 AM | liaison-agent | Weekly performance + planning summary per client |
All three follow the same pattern: query data → format message → send to each paired user.
Steps
3.1 — Create cron scheduler
File: api/src/bot/cron.ts
import cron from 'node-cron'
import { getAllAdapters, getAdapter } from './index.js'
import { createAdminSupabaseClient } from '../utils/supabase.js'
import { dailyBriefingSummary, pendingReminders } from '../services/posts.service.js'
import { briefingPrompt } from './prompts/briefing.js'
import { getModel } from '../utils/llm-models.js'
export function startCronJobs() {
// Morning briefing — daily at 9:00 AM
cron.schedule('0 9 * * *', () => void runMorningBriefing())
// Approval reminders — daily at 10:00 AM
cron.schedule('0 10 * * *', () => void runApprovalReminders())
// Weekly summary — Monday at 9:00 AM
cron.schedule('0 9 * * 1', () => void runWeeklySummary())
}
3.2 — Implement morning briefing
async function runMorningBriefing() {
const supabase = createAdminSupabaseClient()
// Get all active paired users
const { data: users } = await supabase
.from('messaging_users')
.select('user_id, channel, external_id')
.eq('is_active', true)
if (!users?.length) return
// Deduplicate by user_id (a user may be on multiple channels — send to each)
for (const user of users) {
try {
// Fetch summary data using existing service function
const summary = await dailyBriefingSummary(user.user_id)
if (!summary || summary.error) continue
// Use LLM to compose a friendly briefing from raw data
const llm = getModel('fast')
const message = await llm.invoke([
{ role: 'system', content: briefingPrompt },
{ role: 'user', content: JSON.stringify(summary) },
])
const adapter = getAdapter(user.channel)
await adapter?.sendMessage(user.external_id, {
text: message.content as string,
format: 'markdown',
})
} catch (err) {
logger.error({ err, userId: user.user_id }, 'Morning briefing failed')
}
}
}
3.3 — Implement approval reminders
async function runApprovalReminders() {
const supabase = createAdminSupabaseClient()
const { data: users } = await supabase
.from('messaging_users')
.select('user_id, channel, external_id')
.eq('is_active', true)
if (!users?.length) return
for (const user of users) {
try {
const reminders = await pendingReminders(user.user_id)
if (!reminders?.length) continue
const clientNames = reminders.map((r: any) => r.customer_name).join(', ')
const count = reminders.length
await getAdapter(user.channel)?.sendMessage(user.external_id, {
text: `📋 You have ${count} post${count > 1 ? 's' : ''} awaiting review for: ${clientNames}. Check your dashboard to approve or provide feedback.`,
format: 'markdown',
})
} catch (err) {
logger.error({ err, userId: user.user_id }, 'Approval reminder failed')
}
}
}
3.4 — Implement weekly summary
async function runWeeklySummary() {
// Similar pattern to morning briefing but with weekly scope
// Uses a different prompt template and aggregates the past 7 days
// Includes: posts published, engagement stats, upcoming schedule, suggestions
const supabase = createAdminSupabaseClient()
const { data: users } = await supabase
.from('messaging_users')
.select('user_id, channel, external_id')
.eq('is_active', true)
if (!users?.length) return
for (const user of users) {
try {
// Reuse clientDashboard() or create a weeklyDashboard() variant
const dashboard = await clientDashboard(user.user_id)
if (!dashboard) continue
const llm = getModel('fast')
const message = await llm.invoke([
{ role: 'system', content: weeklySummaryPrompt },
{ role: 'user', content: JSON.stringify(dashboard) },
])
await getAdapter(user.channel)?.sendMessage(user.external_id, {
text: message.content as string,
format: 'markdown',
})
} catch (err) {
logger.error({ err, userId: user.user_id }, 'Weekly summary failed')
}
}
}
3.5 — Wire cron into bot startup
File: api/src/bot/index.ts (modify)
import { startCronJobs } from './cron.js'
export async function startBot() {
// ... adapter setup from Phase 1 ...
// Start cron jobs after adapters are ready
startCronJobs()
}
3.6 — Add timezone configuration
File: api/src/config/env.ts (modify)
CRON_TIMEZONE: process.env.CRON_TIMEZONE || 'UTC'
Pass to node-cron:
cron.schedule('0 9 * * *', callback, { timezone: env.CRON_TIMEZONE })
Design Notes
Multi-channel delivery
Cron jobs iterate messaging_users rows, not users. If a user is paired on both Telegram and WhatsApp, they receive the briefing on both channels. This is intentional — users can unpair channels they don't want notifications on.
To send to only the user's preferred channel in future, add a preferred boolean to messaging_users or a user preference table.
Failure isolation
Each user's cron delivery is wrapped in try/catch. A failure for one user doesn't block others. Errors are logged with userId for debugging.
No agent involvement
Cron jobs use the 'fast' LLM tier to format raw data into friendly messages. They do NOT invoke the full agent (with tools) — that would be slow and unnecessary. The briefing/reminder content is generated from structured data, not freeform conversation.
Files Created
| File | Purpose |
|---|---|
api/src/bot/cron.ts | node-cron scheduler with three jobs |
api/src/bot/prompts/briefing.ts | Briefing prompt template (if not already created in Phase 2) |
Files Modified
| File | Change |
|---|---|
api/src/bot/index.ts | Call startCronJobs() in startBot() |
api/src/config/env.ts | Add CRON_TIMEZONE |
Verification
- [x] Morning briefing fires at 9 AM (or manually trigger) → paired users receive formatted summary
- [x] Approval reminders fire at 10 AM → users with pending posts >24h get a reminder
- [x] Weekly summary fires Monday 9 AM → users receive weekly recap
- [x] Multi-channel: user paired on two channels receives message on both
- [x] Failure for one user doesn't block delivery to others
- [x] No cron runs if no users are paired (graceful no-op)
- [x] Cron timezone is configurable via
CRON_TIMEZONEenv var - [x] Cron job logs include user context for debugging