AI Agent Automation Scheduling: Cron Jobs, Real Examples, and Reliable Operations
A builderβs guide to AI agent automation scheduling with cron: syntax basics, 8 real examples, monitoring, retries, and error handling.
Step-by-step guide to building a web dashboard to monitor all your OpenClaw agents β status, message logs, heartbeat monitoring, and cost metrics.
If you're running more than one OpenClaw agent, things get complicated fast. Which agent is alive? When did it last respond? How much did it cost today? Without visibility into these questions, you're flying blind.
This tutorial walks through building a simple but powerful mission control dashboard using Next.js and the filesystem APIs that OpenClaw exposes natively. No third-party monitoring service required.
A web dashboard that shows:
The dashboard reads directly from your OpenClaw workspace files β it doesn't need to talk to the agents at all. This means it works even if an agent crashes.
First, ensure your OpenClaw workspace is organized consistently. The dashboard depends on this structure:
~/openclaw-workspace/
βββ agents/
β βββ main/
β β βββ system-prompt.md
β β βββ memory.md
β β βββ HEARTBEAT.md β We'll read this
β β βββ logs/
β β βββ 2026-01-22.log β Message logs
β β βββ costs.json β Cost tracking
β βββ coder/
β β βββ HEARTBEAT.md
β β βββ logs/
β βββ researcher/
β βββ HEARTBEAT.md
β βββ logs/
βββ config/
βββ openclaw.config.json
OpenClaw writes a timestamp to HEARTBEAT.md every 30 seconds while an agent is running. The file looks like this:
## Agent Heartbeat
**Agent**: main
**Status**: running
**Last Beat**: 2026-01-22T14:23:45.123Z
**Uptime**: 4h 23m
**Messages Today**: 47
**Tokens Today**: 38,291
**Cost Today**: $0.82
If the last beat timestamp is more than 60 seconds old, the agent is likely down. This is how we detect crashed agents.
Create a new Next.js app alongside your workspace:
mkdir ~/openclaw-dashboard
cd ~/openclaw-dashboard
npx create-next-app@latest . --typescript --tailwind --app --yes
Install dependencies:
npm install gray-matter date-fns
Create lib/agents.ts to read workspace data:
import fs from 'fs';
import path from 'path';
const WORKSPACE = process.env.OPENCLAW_WORKSPACE ||
path.join(process.env.HOME!, 'openclaw-workspace');
export interface AgentStatus {
id: string;
name: string;
status: 'running' | 'stale' | 'offline';
lastBeat: Date | null;
uptimeStr: string;
messagesToday: number;
costToday: number;
tokensToday: number;
}
export function getAgentStatus(agentId: string): AgentStatus {
const heartbeatPath = path.join(WORKSPACE, 'agents', agentId, 'HEARTBEAT.md');
if (!fs.existsSync(heartbeatPath)) {
return {
id: agentId,
name: agentId,
status: 'offline',
lastBeat: null,
uptimeStr: 'N/A',
messagesToday: 0,
costToday: 0,
tokensToday: 0,
};
}
const content = fs.readFileSync(heartbeatPath, 'utf-8');
const lastBeatMatch = content.match(/\*\*Last Beat\*\*: (.+)/);
const messagesMatch = content.match(/\*\*Messages Today\*\*: ([\d,]+)/);
const costMatch = content.match(/\*\*Cost Today\*\*: \$([\d.]+)/);
const tokensMatch = content.match(/\*\*Tokens Today\*\*: ([\d,]+)/);
const uptimeMatch = content.match(/\*\*Uptime\*\*: (.+)/);
const lastBeat = lastBeatMatch
? new Date(lastBeatMatch[1].trim())
: null;
const now = new Date();
const secondsSincebeat = lastBeat
? (now.getTime() - lastBeat.getTime()) / 1000
: Infinity;
let status: AgentStatus['status'] = 'offline';
if (secondsSincebeat < 60) status = 'running';
else if (secondsSincebeat < 300) status = 'stale';
return {
id: agentId,
name: agentId,
status,
lastBeat,
uptimeStr: uptimeMatch ? uptimeMatch[1].trim() : 'Unknown',
messagesToday: messagesMatch
? parseInt(messagesMatch[1].replace(',', '')) : 0,
costToday: costMatch ? parseFloat(costMatch[1]) : 0,
tokensToday: tokensMatch
? parseInt(tokensMatch[1].replace(',', '')) : 0,
};
}
export function getAllAgents(): AgentStatus[] {
const agentsDir = path.join(WORKSPACE, 'agents');
if (!fs.existsSync(agentsDir)) return [];
const agentDirs = fs.readdirSync(agentsDir).filter(d =>
fs.statSync(path.join(agentsDir, d)).isDirectory()
);
return agentDirs.map(id => getAgentStatus(id));
}
Create lib/logs.ts:
import fs from 'fs';
import path from 'path';
const WORKSPACE = process.env.OPENCLAW_WORKSPACE ||
path.join(process.env.HOME!, 'openclaw-workspace');
export interface LogEntry {
timestamp: Date;
agentId: string;
channel: string;
role: 'user' | 'assistant';
content: string;
tokens?: number;
cost?: number;
}
export function getRecentLogs(agentId: string, limit = 20): LogEntry[] {
const logsDir = path.join(WORKSPACE, 'agents', agentId, 'logs');
if (!fs.existsSync(logsDir)) return [];
const today = new Date().toISOString().split('T')[0];
const logFile = path.join(logsDir, `${today}.log`);
if (!fs.existsSync(logFile)) return [];
const content = fs.readFileSync(logFile, 'utf-8');
const lines = content.split('\n').filter(Boolean);
// Log format: [ISO_TIMESTAMP] [CHANNEL] [ROLE] [TOKENS] MESSAGE
return lines
.slice(-limit)
.map(line => {
const match = line.match(
/^\[(.+?)\] \[(.+?)\] \[(.+?)\](?: \[(\d+)t \$([\d.]+)\])? (.+)$/
);
if (!match) return null;
return {
timestamp: new Date(match[1]),
agentId,
channel: match[2],
role: match[3] as 'user' | 'assistant',
tokens: match[4] ? parseInt(match[4]) : undefined,
cost: match[5] ? parseFloat(match[5]) : undefined,
content: match[6],
};
})
.filter(Boolean) as LogEntry[];
}
export function getRecentLogsAllAgents(limit = 50): LogEntry[] {
const agentsDir = path.join(WORKSPACE, 'agents');
if (!fs.existsSync(agentsDir)) return [];
const agents = fs.readdirSync(agentsDir);
const allLogs = agents.flatMap(id => getRecentLogs(id, limit));
return allLogs
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, limit);
}
Create app/page.tsx:
import { getAllAgents } from '@/lib/agents';
import { getRecentLogsAllAgents } from '@/lib/logs';
import AgentCard from '@/components/AgentCard';
import LogFeed from '@/components/LogFeed';
import CostSummary from '@/components/CostSummary';
export const revalidate = 15; // Refresh every 15 seconds
export default async function Dashboard() {
const agents = getAllAgents();
const logs = getRecentLogsAllAgents(30);
const totalCostToday = agents.reduce((sum, a) => sum + a.costToday, 0);
const totalMessagesToday = agents.reduce((sum, a) => sum + a.messagesToday, 0);
const runningCount = agents.filter(a => a.status === 'running').length;
return (
<div className="min-h-screen bg-gray-950 text-white p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">Mission Control</h1>
<div className="text-sm text-gray-400">
Auto-refreshes every 15s
</div>
</div>
{/* Summary bar */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-gray-900 rounded-lg p-4">
<div className="text-2xl font-bold text-green-400">
{runningCount}/{agents.length}
</div>
<div className="text-gray-400 text-sm">Agents Running</div>
</div>
<div className="bg-gray-900 rounded-lg p-4">
<div className="text-2xl font-bold text-blue-400">
{totalMessagesToday.toLocaleString()}
</div>
<div className="text-gray-400 text-sm">Messages Today</div>
</div>
<div className="bg-gray-900 rounded-lg p-4">
<div className="text-2xl font-bold text-amber-400">
${totalCostToday.toFixed(2)}
</div>
<div className="text-gray-400 text-sm">Spend Today</div>
</div>
</div>
{/* Agent cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{agents.map(agent => (
<AgentCard key={agent.id} agent={agent} />
))}
</div>
{/* Log feed */}
<div className="bg-gray-900 rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Live Message Log</h2>
<LogFeed logs={logs} />
</div>
</div>
</div>
);
}
Create components/AgentCard.tsx:
import { AgentStatus } from '@/lib/agents';
import { formatDistanceToNow } from 'date-fns';
const statusColors = {
running: 'bg-green-500',
stale: 'bg-yellow-500',
offline: 'bg-red-500',
};
const statusLabels = {
running: 'Running',
stale: 'Stale (>1min)',
offline: 'Offline',
};
export default function AgentCard({ agent }: { agent: AgentStatus }) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-lg capitalize">{agent.id}</h3>
<span className={`flex items-center gap-1.5 text-xs font-medium`}>
<span className={`w-2 h-2 rounded-full ${statusColors[agent.status]}`} />
{statusLabels[agent.status]}
</span>
</div>
<div className="space-y-1 text-sm text-gray-400">
<div className="flex justify-between">
<span>Last heartbeat</span>
<span className="text-white">
{agent.lastBeat
? formatDistanceToNow(agent.lastBeat, { addSuffix: true })
: 'Never'
}
</span>
</div>
<div className="flex justify-between">
<span>Uptime</span>
<span className="text-white">{agent.uptimeStr}</span>
</div>
<div className="flex justify-between">
<span>Messages today</span>
<span className="text-white">{agent.messagesToday}</span>
</div>
<div className="flex justify-between">
<span>Cost today</span>
<span className="text-amber-400">${agent.costToday.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span>Tokens today</span>
<span className="text-white">{agent.tokensToday.toLocaleString()}</span>
</div>
</div>
</div>
);
}
For a dashboard that actually updates without page refresh, use React's useRouter with an interval:
// app/layout.tsx - Add this to a client component wrapper
'use client';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export function AutoRefresh({ intervalMs = 15000 }: { intervalMs?: number }) {
const router = useRouter();
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, intervalMs);
return () => clearInterval(interval);
}, [router, intervalMs]);
return null;
}
Add <AutoRefresh /> to your layout's body, and Next.js will re-fetch the server data every 15 seconds without a full page reload.
For monthly cost tracking, have your agents write a costs.json file alongside the heartbeat. A simple structure:
{
"2026-01-22": {
"tokens_input": 28000,
"tokens_output": 10291,
"cost_usd": 0.82,
"messages": 47
},
"2026-01-21": {
"tokens_input": 31200,
"tokens_output": 11800,
"cost_usd": 0.94,
"messages": 52
}
}
Read this in lib/costs.ts and render a simple bar chart using just CSS:
export function getMonthlySpend(agentId: string): number {
const costsPath = path.join(WORKSPACE, 'agents', agentId, 'logs', 'costs.json');
if (!fs.existsSync(costsPath)) return 0;
const costs = JSON.parse(fs.readFileSync(costsPath, 'utf-8'));
const thisMonth = new Date().toISOString().slice(0, 7); // "2026-01"
return Object.entries(costs)
.filter(([date]) => date.startsWith(thisMonth))
.reduce((sum, [, data]: [string, any]) => sum + data.cost_usd, 0);
}
The dashboard uses filesystem access, so it can't be deployed to Vercel or similar hosts. Run it locally on the same machine as your workspace:
npm run build
PORT=4000 npm start
Access it at http://localhost:4000. For remote access, use a SSH tunnel:
# On your remote machine:
ssh -L 4000:localhost:4000 user@your-server.com
# Then access http://localhost:4000 locally
Or set up Tailscale for a VPN that makes your dashboard accessible from anywhere.
Once you have the basics working, consider adding:
A good dashboard dramatically reduces the time you spend wondering "is my agent working?" β you'll know at a glance.
Weekly tips, tutorials, and real-world agent workflows β straight to your inbox. Join 1,200+ AI agent builders who read it every Friday.
Subscribe for FreeNo spam. Unsubscribe any time.
A builderβs guide to AI agent automation scheduling with cron: syntax basics, 8 real examples, monitoring, retries, and error handling.
A practical guide to AI agent memory: short-term, long-term, and episodic memory patterns, with real examples and implementation tradeoffs.
A deep dive into building autoDream β a 4-phase memory consolidation pipeline that lets AI agents review, compress, and heal their own memories while they sleep.