Ai-Mee Help Centre
Home
Features
How-To Guides
FAQ
Need Help?
Home
Features
How-To Guides
FAQ
Need Help?

Phase 3: Cron Jobs

Depends on: Phase 1 (Bot Core — specifically ChannelAdapter.sendMessage() and messaging_users table)
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

NameScheduleGoClaw AgentWhat it does
morning-briefing9 AM dailybriefing-agentFetches daily summary, composes personalized message per client
approval-reminders10 AM dailyliaison-agentFinds posts in pending_review >24h, sends gentle reminder
weekly-summaryMonday 9 AMliaison-agentWeekly 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

FilePurpose
api/src/bot/cron.tsnode-cron scheduler with three jobs
api/src/bot/prompts/briefing.tsBriefing prompt template (if not already created in Phase 2)

Files Modified

FileChange
api/src/bot/index.tsCall startCronJobs() in startBot()
api/src/config/env.tsAdd 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_TIMEZONE env var
  • [x] Cron job logs include user context for debugging