Skip to main content

Cron Scheduling

ClawDesk includes a built-in cron scheduling system for running automated tasks—daily summaries, periodic health checks, scheduled messages, and more. Tasks run through the agent pipeline with full access to tools, memory, and channels.

Architecture​

Key Components​

ComponentDescription
CronManagerTop-level orchestrator; manages task lifecycle, scheduling, and state
CronExecutorExecutes tasks in isolated contexts with timeout enforcement
HeartbeatMonitors task health and detects stuck/hung executions
Schedule ParserParses cron expressions (standard + extended syntax)
Overlap GuardPrevents concurrent executions of the same task

Schedule Syntax​

ClawDesk supports standard cron syntax and convenient shorthand expressions:

Standard Cron Format​

┌───────── minute (0–59)
│ ┌───────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌───────── month (1–12 or JAN–DEC)
│ │ │ │ ┌───────── day of week (0–7 or SUN–SAT, 0 and 7 = Sunday)
│ │ │ │ │
* * * * *

Examples​

ExpressionDescription
0 9 * * *Every day at 9:00 AM
*/15 * * * *Every 15 minutes
0 9 * * MON-FRIWeekdays at 9:00 AM
0 0 1 * *First day of every month at midnight
30 14 * * WEDEvery Wednesday at 2:30 PM
0 */4 * * *Every 4 hours
0 8,12,17 * * *At 8 AM, 12 PM, and 5 PM daily
0 9 1,15 * *1st and 15th of each month at 9 AM

Shorthand Expressions​

ShorthandEquivalentDescription
@yearly / @annually0 0 1 1 *Once a year (Jan 1)
@monthly0 0 1 * *First of every month
@weekly0 0 * * 0Every Sunday
@daily / @midnight0 0 * * *Every day at midnight
@hourly0 * * * *Every hour
@every 30mEvery 30 minutes
@every 2hEvery 2 hours
@every 1dEvery 24 hours
tip

Use @every for simple intervals. It's easier to read and supports arbitrary durations:

schedule = "@every 45m"

Task Configuration​

Defining Tasks in TOML​

# A daily summary task
[[cron.tasks]]
id = "daily-summary"
name = "Daily Conversation Summary"
schedule = "0 9 * * MON-FRI"
enabled = true
channel = "tg_admin"
timezone = "America/New_York"

[cron.tasks.message]
text = """
Generate a summary of all conversations from the past 24 hours.
Include: total messages, active channels, common topics, and any unresolved issues.
"""
provider = "anthropic"
model = "claude-sonnet-4-20250514"
tools = ["knowledge_base"]

[cron.tasks.execution]
timeout_secs = 60
max_retries = 2
overlap_policy = "skip" # "skip" | "queue" | "cancel_previous"

# A periodic health check
[[cron.tasks]]
id = "health-check"
name = "System Health Check"
schedule = "@every 30m"
enabled = true
channel = "tg_admin"

[cron.tasks.message]
text = "Run a system health check and report any issues."
tools = ["system_status", "metrics_query"]

[cron.tasks.execution]
timeout_secs = 30
overlap_policy = "skip"

# A weekly report
[[cron.tasks]]
id = "weekly-report"
name = "Weekly Analytics Report"
schedule = "0 10 * * MON"
enabled = true
channel = "sl_work"

[cron.tasks.message]
text = """
Generate a weekly analytics report covering:
1. Message volume by channel
2. Provider usage and costs
3. Error rates and top errors
4. User engagement metrics
Format as a Slack-friendly message with sections.
"""
tools = ["metrics_query", "cost_tracker"]

[cron.tasks.execution]
timeout_secs = 120
max_retries = 3

Via CLI​

# Add a task interactively
clawdesk cron add

# Add a task with flags
clawdesk cron add \
--id "daily-summary" \
--name "Daily Summary" \
--schedule "0 9 * * MON-FRI" \
--channel tg_admin \
--message "Summarize yesterday's conversations" \
--timezone "America/New_York"

# List all tasks
clawdesk cron list

Example output of clawdesk cron list:

┌────────────────┬──────────────────────┬─────────────────┬─────────┬──────────────────┬───────────┐
│ ID │ Name │ Schedule │ Status │ Last Run │ Next Run │
├────────────────┼──────────────────────┼─────────────────┼─────────┼──────────────────┼───────────┤
│ daily-summary │ Daily Summary │ 0 9 * * MON-FRI │ Active │ 2026-02-16 09:00 │ in 14h │
│ health-check │ Health Check │ @every 30m │ Active │ 2 min ago │ in 28m │
│ weekly-report │ Weekly Report │ 0 10 * * MON │ Active │ 2026-02-10 10:00 │ in 5d │
│ cleanup │ Session Cleanup │ 0 3 * * * │ Paused │ 2026-02-15 03:00 │ — │
└────────────────┴──────────────────────┴─────────────────┴─────────┴──────────────────┴───────────┘

Execution Model​

Isolated Execution​

Each cron task runs in an isolated execution context:

pub struct CronExecutor {
pipeline: Arc<AgentPipeline>,
channel_registry: Arc<ChannelRegistry>,
}

impl CronExecutor {
pub async fn execute(&self, task: &CronTask) -> Result<ExecutionResult> {
// Create an isolated context for this execution
let ctx = ExecutionContext::new()
.with_task_id(&task.id)
.with_timeout(task.execution.timeout)
.with_provider_override(task.message.provider.as_deref())
.with_model_override(task.message.model.as_deref());

// Build an inbound message from the task
let message = InboundMessage::from_cron(task);

// Process through the agent pipeline
let response = tokio::time::timeout(
task.execution.timeout,
self.pipeline.process_with_context(message, ctx),
).await??;

// Send to the target channel
if let Some(channel_id) = &task.channel {
let channel = self.channel_registry.get(channel_id)?;
channel.send(response.into()).await?;
}

Ok(ExecutionResult {
task_id: task.id.clone(),
started_at: Instant::now(),
duration: response.duration,
status: ExecutionStatus::Success,
output_preview: response.text_preview(200),
})
}
}

Overlap Prevention​

Three overlap policies control what happens when a task is triggered while a previous execution is still running:

PolicyBehavior
skipSkip the new execution, keep the running one (default)
queueQueue the new execution to run after the current one finishes
cancel_previousCancel the running execution and start a new one
[cron.tasks.execution]
overlap_policy = "skip"
warning

Use cancel_previous with caution. If a task involves tool calls with side effects (e.g., sending emails, creating tickets), cancelling mid-execution may leave operations in an incomplete state.

Timeout Enforcement​

Every cron task has a configurable timeout:

[cron.tasks.execution]
timeout_secs = 60 # per-execution timeout
max_retries = 2 # retry on failure
retry_delay_secs = 10 # delay between retries

If a task exceeds its timeout, it's forcefully terminated and marked as TimedOut in the execution history.


Heartbeat System​

The heartbeat system monitors running tasks and detects hung executions:

pub struct Heartbeat {
interval: Duration,
stale_threshold: Duration,
running_tasks: Arc<DashMap<TaskId, HeartbeatEntry>>,
}

pub struct HeartbeatEntry {
pub task_id: TaskId,
pub started_at: Instant,
pub last_heartbeat: Instant,
pub progress: Option<String>,
}

impl Heartbeat {
/// Start the heartbeat monitor
pub async fn start(&self) {
loop {
tokio::time::sleep(self.interval).await;

for entry in self.running_tasks.iter() {
if entry.last_heartbeat.elapsed() > self.stale_threshold {
tracing::warn!(
task_id = %entry.task_id,
elapsed = ?entry.last_heartbeat.elapsed(),
"Task heartbeat stale — possible hang"
);
// Optionally kill the task
}
}
}
}

/// Record a heartbeat from a running task
pub fn beat(&self, task_id: &TaskId, progress: Option<String>) {
if let Some(mut entry) = self.running_tasks.get_mut(task_id) {
entry.last_heartbeat = Instant::now();
entry.progress = progress;
}
}
}

Configure the heartbeat system:

[cron.heartbeat]
enabled = true
interval_secs = 10 # how often to check
stale_threshold_secs = 60 # time without heartbeat before alert
kill_on_stale = false # kill stale tasks automatically
alert_channel = "tg_admin" # send alerts to this channel

Monitoring​

Execution History​

# View execution history for a task
clawdesk cron history daily-summary --limit 10

# Example output:
# ┌──────────────────────┬──────────┬──────────┬──────────────────────────────┐
# │ Executed At │ Duration │ Status │ Output Preview │
# ├──────────────────────┼──────────┼──────────┼──────────────────────────────┤
# │ 2026-02-17 09:00:02 │ 4.2s │ ✅ OK │ Yesterday's summary: 142... │
# │ 2026-02-14 09:00:01 │ 3.8s │ ✅ OK │ Thursday summary: 98 me... │
# │ 2026-02-13 09:00:03 │ 12.1s │ ⚠️ Retry │ Timed out, retried succ... │
# │ 2026-02-12 09:00:01 │ 3.5s │ ✅ OK │ Tuesday summary: 76 mes... │
# └──────────────────────┴──────────┴──────────┴──────────────────────────────┘

Metrics​

MetricTypeDescription
clawdesk_cron_executions_totalCounterTotal executions by task and status
clawdesk_cron_duration_secondsHistogramExecution duration by task
clawdesk_cron_errors_totalCounterErrors by task and error type
clawdesk_cron_skipped_totalCounterSkipped executions (overlap)
clawdesk_cron_active_tasksGaugeCurrently running tasks
clawdesk_cron_heartbeat_staleCounterStale heartbeat events

Manual Triggers​

# Run a task immediately (outside its schedule)
clawdesk cron run daily-summary

# Run with output to terminal
clawdesk cron run daily-summary --output terminal

# Dry run (show what would execute without actually running)
clawdesk cron run daily-summary --dry-run

Advanced Patterns​

Chained Tasks​

Execute tasks in sequence:

[[cron.tasks]]
id = "etl-extract"
schedule = "0 2 * * *"
# ...

[[cron.tasks]]
id = "etl-transform"
schedule = "" # no schedule — triggered only by chain
depends_on = "etl-extract" # runs after etl-extract succeeds

[[cron.tasks]]
id = "etl-load"
schedule = ""
depends_on = "etl-transform"

Conditional Execution​

Tasks can include conditions:

[[cron.tasks]]
id = "weekend-summary"
schedule = "0 10 * * SAT"

[cron.tasks.condition]
# Only run if there were messages in the past week
check = "message_count"
threshold = 10
period = "7d"

Timezone Support​

All schedules support timezone specification:

[[cron.tasks]]
id = "standup-reminder"
schedule = "0 9 * * MON-FRI"
timezone = "America/New_York" # defaults to UTC if unset

[[cron.tasks]]
id = "asia-report"
schedule = "0 9 * * MON-FRI"
timezone = "Asia/Tokyo"
info

When no timezone is specified, schedules use UTC. Always set an explicit timezone for tasks that should run at local business hours.


Troubleshooting​

ProblemSolution
Task not runningCheck enabled = true, verify schedule syntax with clawdesk cron run <id> --dry-run
Task runs at wrong timeVerify timezone setting, check system clock
Task always skippedCheck overlap policy — may be hitting overlap with long-running previous execution
Heartbeat stale alertsIncrease stale_threshold_secs or investigate slow provider responses
Task output not sentVerify channel ID exists and is connected