Discord & Cron Agent Automations

Agentic AI Systems intermediate Building with AI ~25 min read

Overview

Cron-driven agent automations bring the concept of scheduled tasks — long familiar in Unix system administration — into the world of LLM-powered agents. Instead of running a shell script at 6 AM to rotate logs, a cron job triggers an isolated agent turn that calls the Anthropic API with a purpose-built prompt, executes MCP tools to gather data, and delivers the results to a Discord channel. The platform implements this pattern with named cron jobs like morning-briefing, email-sync-check, experiment-analysis, and weekly-digest, each with its own prompt template, Discord delivery mode, and timeout budget.

The critical insight is that each cron job spawns an isolated agent turn — a fresh conversation with no memory of previous executions unless explicitly loaded from persistent storage. This isolation prevents state leakage between runs (a failed Monday briefing cannot corrupt Tuesday’s execution), keeps token budgets predictable, and makes each run independently debuggable. The agent’s entire lifecycle — prompt construction, tool calls, response generation, delivery — is bounded by a timeout, ensuring that a hung API call or runaway tool chain cannot block subsequent scheduled tasks.

Discord serves as both the delivery channel and the human oversight interface. Agents post their outputs to designated channels where team members can review results, react with feedback, or trigger follow-up actions. Different delivery modes — embed messages for structured data, thread replies for long-form analysis, file attachments for generated reports — let each cron job choose the presentation format that best fits its output. This combination of automated generation and human-visible delivery creates a lightweight human-in-the-loop pattern without requiring a custom dashboard or notification system.

Core Concepts

Isolated Agent Turns

Each cron execution creates a fresh agent conversation with no carry-over state from previous runs. The agent receives its prompt, makes tool calls, generates output, and terminates. Any state that needs to persist between runs (e.g., “last email sync timestamp”) must be explicitly read from and written to external storage at the beginning and end of each turn. This statelesness-by-default design prevents subtle bugs where an agent’s behavior drifts based on accumulated context from prior executions.

Timeout Budgets

Every cron job is assigned a maximum execution time (e.g., 120 seconds for morning-briefing, 300 seconds for weekly-digest). If the agent turn exceeds its budget — whether due to a slow API response, an expensive tool chain, or an unexpectedly long LLM generation — the runtime kills the turn and posts a timeout notification to Discord. This prevents a single stuck job from blocking the entire schedule and gives operators immediate visibility into performance degradation.

Discord Delivery Modes

Each cron job specifies how its output should be delivered to Discord: embed for rich structured messages with fields and colors, thread for long-form content posted as a thread under a parent message, file for generated CSVs, PDFs, or images attached as Discord files, or plain for simple text messages. The delivery mode is part of the job configuration, not the agent prompt, so the same agent logic can produce output that is formatted differently depending on the channel and audience.

Prompt Templates with Runtime Context Injection

Cron job prompts are templates that accept runtime variables — current date, last run timestamp, relevant configuration values — injected at execution time. This separation of template from context means the same morning-briefing prompt works across environments (staging vs. production) and time zones without modification. Templates are stored as configuration, not code, making them editable by non-engineers who understand the domain but not the runtime.

Schedule-Driven vs. Event-Driven Triggering

Cron jobs run on fixed schedules (e.g., 0 6 * * * for 6 AM daily), making them predictable and auditable. This contrasts with event-driven triggers (e.g., “run when a new email arrives”) which are reactive but harder to reason about under load. The platform uses cron for regular rituals and reserves event-driven triggering for urgent notifications, keeping the two patterns cleanly separated in configuration.

How It Works

Cron Schedule Registration

At application startup, the scheduler reads the cron job definitions from configuration (typically a JSON or YAML file) and registers each job with a cron library like node-cron or cron in Node.js. Each registration binds a cron expression to a handler function that will execute the agent turn. The scheduler also validates that cron expressions are well-formed and that no two jobs have overlapping IDs, logging warnings for jobs scheduled less than 5 minutes apart to avoid resource contention.

Agent Turn Execution

When a cron trigger fires, the handler constructs the agent’s prompt by rendering the job’s template with current runtime context (date, environment variables, last-run metadata). It then creates a new Anthropic API conversation with the rendered prompt as the user message, the job’s system prompt as the system message, and the job’s MCP tool allowlist as the available tools. The conversation runs in an agentic loop — the agent may make multiple tool calls before producing its final text output — all within the timeout budget enforced by a wrapping Promise.race or AbortController.

If the agent completes within the timeout, its final text output is captured and passed to the delivery layer. If it times out, a structured error object is passed instead, containing the job ID, the duration, and any partial output that was generated before the timeout. Either way, the job’s last-run timestamp and status (success, timeout, error) are persisted to a state file or database for the next execution to reference.

Discord Delivery Pipeline

The delivery layer receives the agent’s output and the job’s configured delivery mode, then constructs the appropriate Discord API call. For embed mode, it parses the agent’s structured output (expected as JSON or markdown with headers) into Discord embed fields with color-coded status indicators. For thread mode, it posts a summary message and then replies to it with the full content, keeping the channel readable. For file mode, it writes the output to a temporary file and uploads it as a Discord attachment. All delivery calls include the job name and execution timestamp in the message footer for traceability.

Implementation Patterns

Cron Job Configuration & Scheduler

import cron from 'node-cron';
import Anthropic from '@anthropic-ai/sdk';

interface CronJobConfig {
  id: string;
  name: string;
  schedule: string;            // cron expression, e.g. "0 6 * * 1-5"
  systemPrompt: string;
  promptTemplate: string;      // supports {{date}}, {{lastRun}} placeholders
  allowedTools: string[];
  discordChannelId: string;
  deliveryMode: 'embed' | 'thread' | 'file' | 'plain';
  timeoutMs: number;
  model: string;
}

interface JobState {
  lastRun: string | null;      // ISO timestamp
  lastStatus: 'success' | 'timeout' | 'error' | null;
  runCount: number;
}

// Example cron job definitions
const cronJobs: CronJobConfig[] = [
  {
    id: 'morning-briefing',
    name: 'Morning Briefing',
    schedule: '0 6 * * 1-5',  // 6 AM weekdays
    systemPrompt: 'You are a daily briefing agent. Summarize key metrics, pending tasks, and notable events.',
    promptTemplate: 'Generate the morning briefing for {{date}}. Last briefing was {{lastRun}}.',
    allowedTools: ['platform.query', 'platform.crm_search', 'platform.calendar_events'],
    discordChannelId: '1234567890',
    deliveryMode: 'embed',
    timeoutMs: 120_000,
    model: 'claude-sonnet-4-20250514',
  },
  {
    id: 'email-sync-check',
    name: 'Email Sync Check',
    schedule: '*/30 * * * *',  // every 30 minutes
    systemPrompt: 'You monitor email sync status. Report any failures or delays.',
    promptTemplate: 'Check email sync status since {{lastRun}}. Flag any sync errors or delays over 5 minutes.',
    allowedTools: ['platform.email_status', 'platform.query'],
    discordChannelId: '1234567891',
    deliveryMode: 'plain',
    timeoutMs: 60_000,
    model: 'claude-haiku-4-5-20251001',
  },
  {
    id: 'weekly-digest',
    name: 'Weekly Digest',
    schedule: '0 9 * * 1',    // Monday 9 AM
    systemPrompt: 'You create comprehensive weekly summaries of all business activity.',
    promptTemplate: 'Create the weekly digest for the week ending {{date}}. Cover pipeline changes, campaign results, and content published.',
    allowedTools: ['platform.query', 'platform.campaign_stats', 'platform.aggregate'],
    discordChannelId: '1234567892',
    deliveryMode: 'thread',
    timeoutMs: 300_000,
    model: 'claude-sonnet-4-20250514',
  },
];

// In-memory state store (replace with persistent storage in production)
const jobStates = new Map<string, JobState>();

function renderTemplate(template: string, vars: Record<string, string>): string {
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
}

// Register all cron jobs
for (const job of cronJobs) {
  cron.schedule(job.schedule, () => executeAgentTurn(job), {
    timezone: 'America/Los_Angeles',
  });
  console.log(`[cron] Registered "${job.id}" with schedule: ${job.schedule}`);
}

Agent Turn Execution with Timeout

async function executeAgentTurn(job: CronJobConfig): Promise<void> {
  const state = jobStates.get(job.id) ?? {
    lastRun: null, lastStatus: null, runCount: 0,
  };

  const now = new Date();
  const prompt = renderTemplate(job.promptTemplate, {
    date: now.toISOString().split('T')[0],
    lastRun: state.lastRun ?? 'never',
  });

  console.log(`[cron] Starting "${job.id}" at ${now.toISOString()}`);

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), job.timeoutMs);

  try {
    const client = new Anthropic();
    const result = await Promise.race([
      runAgentLoop(client, job, prompt),
      new Promise<never>((_, reject) => {
        controller.signal.addEventListener('abort', () =>
          reject(new Error(`Timeout after ${job.timeoutMs}ms`))
        );
      }),
    ]);

    clearTimeout(timeout);

    // Deliver result to Discord
    await deliverToDiscord(job, result, 'success');

    // Update state
    jobStates.set(job.id, {
      lastRun: now.toISOString(),
      lastStatus: 'success',
      runCount: state.runCount + 1,
    });

    console.log(`[cron] "${job.id}" completed successfully`);
  } catch (err) {
    clearTimeout(timeout);
    const errorMsg = err instanceof Error ? err.message : String(err);
    const isTimeout = errorMsg.includes('Timeout');

    await deliverToDiscord(
      job,
      `⚠️ Job "${job.name}" ${isTimeout ? 'timed out' : 'failed'}: ${errorMsg}`,
      isTimeout ? 'timeout' : 'error'
    );

    jobStates.set(job.id, {
      lastRun: now.toISOString(),
      lastStatus: isTimeout ? 'timeout' : 'error',
      runCount: state.runCount + 1,
    });

    console.error(`[cron] "${job.id}" failed: ${errorMsg}`);
  }
}

async function runAgentLoop(
  client: Anthropic,
  job: CronJobConfig,
  prompt: string
): Promise<string> {
  const messages: Anthropic.MessageParam[] = [
    { role: 'user', content: prompt },
  ];

  let response = await client.messages.create({
    model: job.model,
    max_tokens: 4096,
    system: job.systemPrompt,
    messages,
  });

  // Agentic tool-use loop
  while (response.stop_reason === 'tool_use') {
    const toolBlock = response.content.find(
      (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use'
    );
    if (!toolBlock) break;

    // Check tool is in the job's allowlist
    if (!job.allowedTools.includes(toolBlock.name)) {
      messages.push({ role: 'assistant', content: response.content });
      messages.push({
        role: 'user',
        content: [{
          type: 'tool_result',
          tool_use_id: toolBlock.id,
          content: JSON.stringify({ error: 'Tool not permitted for this job' }),
          is_error: true,
        }],
      });
    } else {
      const result = await callMcpTool(toolBlock.name, toolBlock.input);
      messages.push({ role: 'assistant', content: response.content });
      messages.push({
        role: 'user',
        content: [{
          type: 'tool_result',
          tool_use_id: toolBlock.id,
          content: JSON.stringify(result),
        }],
      });
    }

    response = await client.messages.create({
      model: job.model,
      max_tokens: 4096,
      system: job.systemPrompt,
      messages,
    });
  }

  const textBlock = response.content.find(
    (b): b is Anthropic.TextBlock => b.type === 'text'
  );
  return textBlock?.text ?? '(no output)';
}

Practical Applications

The platform’s morning briefing job fires at 6 AM Pacific every weekday. The agent queries the CRM for new leads added overnight, checks the calendar for today’s meetings, and pulls pipeline conversion metrics from the analytics store. The output is delivered as a Discord embed with color-coded fields — green for metrics that improved, red for regressions, yellow for items needing attention. Team members arriving at their desks find a concise, actionable summary waiting in the #daily-ops channel, eliminating the 15-minute manual dashboard review that previously started every morning.

The email-sync-check job runs every 30 minutes and uses the lightweight Haiku model to minimize cost. It queries the email integration’s status endpoint, compares the last-synced timestamp against the current time, and only posts to Discord if the delay exceeds 5 minutes. Most runs produce no output (the sync is healthy), so the channel stays quiet until there’s an actual issue. When a sync failure does occur, the agent includes the error details and a suggested remediation step, giving the on-call engineer enough context to act without opening a dashboard.

The weekly digest demonstrates the thread delivery mode. On Monday mornings, the agent gathers a week’s worth of campaign metrics, pipeline changes, and published content. The orchestrator dispatches to the data-scientist agent for metrics and the content-writer agent for the narrative summary. The result is posted as a thread: the parent message contains a one-paragraph executive summary, and the thread replies contain detailed sections (pipeline, campaigns, content, action items) that interested team members can expand without cluttering the main channel.

Common Pitfalls

Further Reading