How to Schedule Cron Jobs in OpenClaw (Beginner-Friendly Examples)
How to Schedule Cron Jobs in OpenClaw (Beginner-Friendly Examples)

Let's cut right to it: cron jobs are one of the most useful things you can do with an AI agent, and also one of the most frustrating to get right. The idea is simple — make your agent do something on a schedule. Check for new leads every hour. Summarize your analytics every morning. Sync inventory every fifteen minutes. The kind of stuff that, once it works, saves you hours every week.
The problem? Most people set up a cron job, walk away, and discover two weeks later that it stopped working on day three and nobody noticed. Or it ran every single time according to the logs but actually accomplished nothing. Or it quietly burned through $400 in API calls because there was no cost tracking whatsoever.
I've been there. If you've been there, this guide is for you. We're going to walk through how to schedule cron jobs in OpenClaw the right way — with real examples, proper error handling, state persistence, and the kind of observability that means you actually know what your agents are doing while you sleep.
Why Regular Cron + AI Agents Is a Terrible Experience
Before we get into how OpenClaw handles this, let me paint the picture of what "normal" looks like without it, because understanding the pain makes the solution click.
The standard approach is: write a Python script that calls an LLM, slap a cron schedule on it via crontab -e or some cloud scheduler, and hope for the best.
Here's what actually happens:
Silent failures everywhere. Your agent hits a rate limit from your LLM provider, throws an exception, and dies. Cron dutifully reports "job ran" because it did run — it just didn't do anything useful. You find out days later when you notice the reports stopped coming.
Zero observability. You get two log lines: "started" and "finished" (or "started" and nothing, which is worse). What did the agent actually do between those two lines? How many API calls did it make? How many tokens did it consume? What decisions did it make? Complete black box.
No memory between runs. Your news aggregation agent fetches the same articles every single run because it has no idea what it already processed. So you end up writing database connection code, state serialization logic, and deduplication systems — basically building half a framework just to make a cron job work properly.
Testing is miserable. Want to test your cron job? Set it to run every minute, wait, check logs, find a bug, fix it, wait again. Repeat forty times. Then remember to change it back to the real schedule before you push to prod. (You will forget.)
Cost surprises. The monthly bill arrives and it's four figures because your "simple" daily agent was actually making hundreds of GPT-4 calls per run and nobody was tracking it.
OpenClaw solves all of this. Let me show you how.
Your First Scheduled Workflow in OpenClaw
OpenClaw uses the concept of "scheduled workflows" instead of raw cron jobs. The distinction matters: a workflow is an agent execution with built-in state management, tracing, retry logic, and cost tracking. A cron job is just "run this thing at this time."
Here's the simplest possible example:
from openclaw import schedule_workflow
@schedule_workflow(cron="0 9 * * *")
async def daily_summary_agent(ctx):
"""Runs every day at 9 AM"""
metrics = await ctx.fetch_data(source="analytics_db")
summary = await ctx.agent.summarize(metrics)
await ctx.email.send(to="team@company.com", body=summary)
return {"status": "sent", "metrics_count": len(metrics)}
That's it. That @schedule_workflow decorator handles:
- Scheduling via standard cron syntax
- Execution logging for every step
- Cost tracking for all LLM calls
- Error capture with full context
- State persistence between runs
The cron syntax is the same one you already know. 0 9 * * * means "at 9:00 AM every day." */30 * * * * means "every 30 minutes." 0 0 * * 1 means "midnight every Monday." Nothing new to learn there.
But the ctx object — that's where the magic is.
The Context Object: Your Agent's Brain
Every scheduled workflow in OpenClaw receives a context object (ctx) that gives your agent access to everything it needs. Think of it as the difference between giving someone a task on a sticky note versus giving them a task with a full briefing, a memory of everything they've done before, and a way to report back.
Here's a more realistic example — a PR monitoring agent that runs every 30 minutes:
from openclaw import schedule_workflow
@schedule_workflow(cron="*/30 * * * *")
async def pr_monitor_agent(ctx):
"""Check for new PRs and post AI-generated summaries"""
# Fetch open PRs
prs = await ctx.github.get_open_prs()
ctx.trace("Found {count} open PRs", count=len(prs))
# Get previously processed PRs from state
already_processed = ctx.state.get("processed_pr_ids", set())
new_prs = [pr for pr in prs if pr.id not in already_processed]
ctx.trace("Processing {count} new PRs", count=len(new_prs))
for pr in new_prs:
# Generate summary — this LLM call is automatically tracked
summary = await ctx.agent.summarize(pr.diff)
ctx.trace("Summarized PR #{num}: {title}",
num=pr.number, title=pr.title)
# Post the comment
await ctx.github.post_comment(pr.number, summary)
already_processed.add(pr.id)
# Save state for next run
ctx.state.set("processed_pr_ids", already_processed)
return {"processed": len(new_prs), "skipped": len(prs) - len(new_prs)}
Look at what's happening here. The agent remembers which PRs it already processed (ctx.state). It logs each step with context (ctx.trace). Every LLM call through ctx.agent is automatically tracked for cost and token usage. And the return value gets stored as part of the execution history.
When you open the OpenClaw dashboard after this runs, you see something like:
Run #147 — Dec 15, 2026 10:30 AM
├─ Found 6 open PRs (0.2s)
├─ Processing 2 new PRs
├─ Analyzing PR #234 "Add user authentication"
│ ├─ LLM Call: gpt-4 (2,340 tokens, $0.047)
│ ├─ Generated summary (1.8s)
│ └─ Posted comment ✓
├─ Analyzing PR #237 "Refactor payment flow"
│ ├─ LLM Call: gpt-4 (1,890 tokens, $0.038)
│ ├─ Generated summary (1.5s)
│ └─ Posted comment ✓
└─ Total: 3m 48s | Cost: $0.085 | 2 PRs processed, 4 skipped
That is light-years beyond a cron log that just says "job completed."
Handling Rate Limits and Retries (Without Losing Your Mind)
This is the one that gets people. You set up a daily report agent, it works great for a week, then one morning OpenAI's API is slow and your agent times out. No report. No notification. Just silence.
OpenClaw lets you define retry policies directly on the workflow:
from openclaw import schedule_workflow
from openclaw.retry import exponential_backoff
@schedule_workflow(
cron="0 9 * * *",
retry_policy=exponential_backoff(
max_attempts=5,
initial_delay=60, # Wait 1 minute after first failure
max_delay=3600 # Never wait more than 1 hour
)
)
async def daily_report_agent(ctx):
"""Generate and send daily analytics report"""
data = await ctx.fetch_metrics(source="posthog")
# If this call fails due to rate limit, OpenClaw retries automatically
report = await ctx.agent.generate_report(data)
if ctx.is_retry:
ctx.trace("This is retry attempt {n}", n=ctx.retry_count)
await ctx.slack.post(channel="#daily-reports", message=report)
return {"report_length": len(report)}
When a 429 (rate limit) or 503 (service unavailable) error hits, OpenClaw catches it and retries with exponential backoff. First retry after 1 minute, then 2 minutes, then 4, and so on — up to the max you set. If all retries fail, then it alerts you.
The crucial part: it continues from where it left off when possible. If your agent successfully fetched data but failed on the LLM call, the retry doesn't re-fetch the data. It picks up from the last successful step. This is possible because OpenClaw is tracking execution state throughout the workflow, not just wrapping the whole thing in a try-catch.
Here's what this looks like in practice for a content processing agent that handles large batches:
@schedule_workflow(
cron="0 2 * * *", # 2 AM daily
retry_policy=exponential_backoff(max_attempts=3, initial_delay=120)
)
async def content_processor(ctx):
"""Process new articles for the content pipeline"""
articles = await ctx.fetch_new_articles(limit=100)
processed = ctx.state.get("batch_progress", [])
for article in articles:
if article.id in processed:
continue # Skip already-processed in case of retry
summary = await ctx.agent.summarize(article)
await ctx.database.store(article.id, summary)
# Save progress incrementally
processed.append(article.id)
ctx.state.set("batch_progress", processed)
# Clear batch progress for next run
ctx.state.delete("batch_progress")
return {"total_processed": len(articles)}
Before OpenClaw, this developer was hitting rate limits at article 23 and losing the entire batch. Now? Rate limit at article 23, automatic 2-minute backoff, resume at article 24, finish all 100. Total time goes from 15 minutes to 45 minutes — but the success rate goes from "sometimes" to 100%.
Persistent State: Stop Writing Database Code
This is one of my favorite features because it eliminates so much boilerplate. The typical cron + agent setup requires you to:
- Set up a database (PostgreSQL, Redis, SQLite, whatever)
- Write connection logic
- Serialize and deserialize state
- Handle edge cases (first run, corrupted state, migration)
- Write more infrastructure code than actual agent code
In OpenClaw, state just works:
@schedule_workflow(cron="0 */6 * * *") # Every 6 hours
async def news_aggregator(ctx):
"""Aggregate news without duplicates across runs"""
# Automatically loaded from previous run
seen_article_ids = ctx.state.get("seen_articles", set())
last_run = ctx.state.get("last_run_time")
# Fetch articles since last run
articles = await ctx.fetch_articles(since=last_run)
new_articles = [a for a in articles if a.id not in seen_article_ids]
ctx.trace("Found {new} new articles, skipping {old} already seen",
new=len(new_articles),
old=len(articles) - len(new_articles))
# Process only new articles
summaries = await ctx.agent.summarize_batch(new_articles)
await ctx.slack.post(channel="#news", message=format_digest(summaries))
# Persist for next run — no database setup required
ctx.state.update({
"seen_articles": seen_article_ids | {a.id for a in new_articles},
"last_run_time": ctx.timestamp,
"lifetime_articles": ctx.state.get("lifetime_articles", 0) + len(new_articles)
})
return summaries
The ctx.state object persists automatically between runs. You don't set up anything. You don't configure a database. You just .get() and .set() and OpenClaw handles the rest. First run? The .get() calls return default values. Hundredth run? Full history available.
This is especially powerful for agents that need to learn or adapt over time:
@schedule_workflow(cron="0 * * * *") # Hourly
async def support_agent(ctx):
"""Handle support tickets with improving context"""
# Load learned patterns from all previous runs
response_patterns = ctx.state.get("learned_patterns", {})
success_rates = ctx.state.get("category_success_rates", {})
tickets = await ctx.get_new_tickets()
for ticket in tickets:
# Use historical patterns to improve responses
context = {
"known_patterns": response_patterns.get(ticket.category, []),
"historical_success_rate": success_rates.get(ticket.category, "unknown")
}
response = await ctx.agent.respond(ticket, extra_context=context)
await ctx.post_response(ticket.id, response)
# Update patterns for future runs
ctx.state.set("learned_patterns", response_patterns)
ctx.state.set("total_tickets_handled",
ctx.state.get("total_tickets_handled", 0) + len(tickets))
Alerts That Actually Work
Here's the setup that would have saved me (and many others) from discovering failures days after the fact:
from openclaw import schedule_workflow
from openclaw.alerts import slack, email, pagerduty
@schedule_workflow(
cron="*/15 * * * *",
on_failure=slack("#agent-alerts"),
on_success=email("team@company.com", only_if_changed=True)
)
async def inventory_sync(ctx):
"""Sync inventory every 15 minutes"""
changes = await ctx.warehouse_api.get_changes()
for change in changes:
decision = await ctx.agent.optimize_listing(change)
await ctx.shopify.update_product(change.sku, decision)
# Alert on anomalies, not just failures
if len(changes) > 100:
ctx.alert.warning(f"Unusual spike: {len(changes)} inventory changes")
return {"synced": len(changes)}
When this fails, you get a Slack message like:
❌ inventory_sync failed at 2:15 PM
Error: Warehouse API timeout after 30s
Last 12 runs: 11 successes, 1 failure
Retry scheduled: 2:20 PM
View details: [dashboard link]
For critical business processes, you can set up graduated alerting:
@schedule_workflow(
cron="*/15 * * * *",
on_failure=[
slack("#ops", if_consecutive_failures=1), # Slack on first failure
slack("#ops-urgent", if_consecutive_failures=3), # Urgent channel on 3rd
pagerduty(severity="high", if_consecutive_failures=5) # Page someone on 5th
]
)
One failure? A heads-up in Slack. Three in a row? Now it's in the urgent channel. Five consecutive failures? Someone's getting paged. This is the kind of production-grade alerting that takes weeks to build from scratch.
Cost Controls: Don't Get Surprised
The $400 surprise bill story I mentioned earlier? Here's how you prevent it:
@schedule_workflow(
cron="0 9 * * *",
cost_limit=5.00 # Hard stop if a single run exceeds $5
)
async def daily_analysis(ctx):
articles = await ctx.fetch_articles(limit=200)
summaries = []
for article in articles:
# Cost is tracked in real-time per call
summary = await ctx.agent.summarize(article, model="gpt-4")
summaries.append(summary)
# Check budget mid-execution
if ctx.cost_so_far > 4.00:
ctx.trace("Approaching cost limit, stopping early")
break
return {"processed": len(summaries), "cost": ctx.cost_so_far}
OpenClaw tracks cost per LLM call in real time. In the dashboard, you see cost trends over time: "This workflow cost $1.20/day last week, $1.85/day this week — 54% increase." That's the kind of visibility that prevents bill shock.
Testing Without Waiting
This one sounds small but it's a massive quality-of-life improvement. Testing cron jobs normally means waiting for the schedule to trigger. With OpenClaw:
# In development: run immediately
# $ openclaw run daily_summary_agent --now
# Or programmatically:
if __name__ == "__main__":
from openclaw.testing import run_immediately
result = await run_immediately(daily_summary_agent)
print(f"Status: {result.status}")
print(f"Cost: ${result.cost}")
print(f"Output: {result.output}")
For proper test suites:
from openclaw.testing import WorkflowTest
def test_daily_summary():
test = WorkflowTest(daily_summary_agent)
# Mock external dependencies
test.mock("fetch_data", return_value={"users": 1000, "revenue": 50000})
test.mock("agent.summarize", return_value="Test summary")
result = test.run()
assert result.status == "success"
assert result.agent_calls == 1
assert result.cost < 1.00
You can iterate on your agent logic in seconds, not minutes. Write, test, fix, test, ship. The way development should work.
Quick Reference: Cron Syntax Cheat Sheet
Since you'll be writing cron expressions, here's a cheat sheet of common patterns:
# Every minute (good for testing, terrible for prod)
cron="* * * * *"
# Every 15 minutes
cron="*/15 * * * *"
# Every hour on the hour
cron="0 * * * *"
# Every day at 9 AM
cron="0 9 * * *"
# Every Monday at midnight
cron="0 0 * * 1"
# First day of every month at 6 AM
cron="0 6 1 * *"
# Every weekday at 8:30 AM
cron="30 8 * * 1-5"
# Every 6 hours
cron="0 */6 * * *"
# Twice a day (9 AM and 5 PM)
cron="0 9,17 * * *"
The format is: minute hour day-of-month month day-of-week. Five fields, same as traditional cron. Nothing weird or custom here.
Putting It All Together: A Production-Ready Example
Let me show you a complete, production-ready scheduled workflow that combines everything we've talked about:
from openclaw import schedule_workflow
from openclaw.retry import exponential_backoff
from openclaw.alerts import slack
@schedule_workflow(
cron="0 8 * * 1-5", # Every weekday at 8 AM
retry_policy=exponential_backoff(
max_attempts=3,
initial_delay=300 # 5 minutes between retries
),
cost_limit=10.00,
on_failure=slack("#team-alerts"),
on_success=slack("#daily-digest", only_if="results.lead_count > 0")
)
async def lead_enrichment_agent(ctx):
"""
Fetch new leads from CRM, enrich with AI research,
and update records — every weekday morning.
"""
# Load state from previous runs
last_processed_id = ctx.state.get("last_lead_id", 0)
enrichment_stats = ctx.state.get("weekly_stats", {
"total_enriched": 0,
"total_cost": 0.0,
"week_start": ctx.timestamp
})
# Fetch new leads since last run
new_leads = await ctx.crm.get_leads(since_id=last_processed_id)
ctx.trace("Found {count} new leads to enrich", count=len(new_leads))
enriched = []
for lead in new_leads:
# AI-powered research on each lead
research = await ctx.agent.research_company(
company=lead.company,
context="B2B SaaS sales qualification"
)
# AI scoring
score = await ctx.agent.qualify_lead(
lead_data=lead,
research=research,
criteria=ctx.state.get("scoring_criteria", "default")
)
# Update CRM
await ctx.crm.update_lead(lead.id, {
"enrichment_data": research,
"ai_score": score,
"enriched_at": ctx.timestamp
})
enriched.append({"id": lead.id, "score": score})
ctx.trace("Enriched lead {id}: score {score}",
id=lead.id, score=score)
# Update persistent state
if new_leads:
ctx.state.set("last_lead_id", new_leads[-1].id)
enrichment_stats["total_enriched"] += len(enriched)
enrichment_stats["total_cost"] += ctx.cost_so_far
ctx.state.set("weekly_stats", enrichment_stats)
return {
"lead_count": len(enriched),
"high_score_leads": [l for l in enriched if l["score"] > 80],
"run_cost": ctx.cost_so_far
}
This agent runs every weekday at 8 AM, enriches new CRM leads with AI research, scores them, updates the CRM, tracks costs, retries on failure, sends Slack alerts, and maintains state across runs. And it's readable. You can look at this and understand exactly what's happening.
Skip the Setup: Get Running in Minutes
If you've read this far, you're probably either excited to build this or dreading the initial setup. I get it — even with OpenClaw making the actual workflow code clean, there's still configuration, skill setup, and the initial "figuring out how everything connects" phase.
Here's my honest recommendation: if you don't want to set all this up manually, Felix's OpenClaw Starter Pack on Claw Mart is worth the $29. It includes pre-configured skills for exactly the kind of scheduled workflows we've been talking about — state management, retry logic, alerting, cost tracking — all wired up and ready to go. I spent a full weekend configuring my first production workflow from scratch; this bundle would have saved me that time. It's not magic, but it's a really solid starting point that you can customize from there.
What to Build First
If you're new to scheduled workflows in OpenClaw, here's my suggested progression:
-
Start simple. A daily summary agent that fetches data from one source and posts to Slack. Get comfortable with
@schedule_workflowandctx. -
Add state. Modify your agent to remember what it already processed. Use
ctx.stateto avoid duplicate work. -
Add error handling. Set up retry policies and Slack alerts. Break your agent on purpose and make sure the alerts work.
-
Add cost controls. Set a
cost_limitand watch the dashboard for a week. You'll be surprised what you learn about your agent's actual spending patterns. -
Go multi-step. Build a workflow with 3+ stages that depend on each other. This is where OpenClaw's tracing really shines.
The goal is to get to a place where your agents run reliably in the background, you get notified when something goes wrong, and you have full visibility into what they're doing and what they're costing you. That's the dream, and it's very achievable with OpenClaw's scheduling system.
Stop babysitting your cron jobs. Set them up right once, and let them work.
Recommended for this post
