feat: add multi-skill cron editing and docs

This commit is contained in:
teknium1 2026-03-14 19:18:10 -07:00
parent df5c61b37c
commit c3ea620796
19 changed files with 968 additions and 407 deletions

View file

@ -516,7 +516,8 @@ def get_cute_tool_message(
if tool_name == "cronjob":
action = args.get("action", "?")
if action == "create":
label = args.get("name") or args.get("skill") or args.get("prompt", "task")
skills = args.get("skills") or ([] if not args.get("skill") else [args.get("skill")])
label = args.get("name") or (skills[0] if skills else None) or args.get("prompt", "task")
return _wrap(f"┊ ⏰ cron create {_trunc(label, 24)} {dur}")
if action == "list":
return _wrap(f"┊ ⏰ cron listing {dur}")

334
cli.py
View file

@ -429,7 +429,7 @@ from hermes_cli import callbacks as _callbacks
from toolsets import get_all_toolsets, get_toolset_info, resolve_toolset, validate_toolset
# Cron job system for scheduled tasks (execution is handled by the gateway)
from cron import create_job, list_jobs, remove_job, get_job, pause_job, resume_job, trigger_job
from cron import get_job
# Resource cleanup imports for safe shutdown (terminal VMs, browser sessions)
from tools.terminal_tool import cleanup_all_environments as _cleanup_all_terminals
@ -2588,162 +2588,248 @@ class HermesCLI:
def _handle_cron_command(self, cmd: str):
"""Handle the /cron command to manage scheduled tasks."""
parts = cmd.split(maxsplit=2)
if len(parts) == 1:
# /cron - show help and list
import shlex
from tools.cronjob_tools import cronjob as cronjob_tool
def _cron_api(**kwargs):
return json.loads(cronjob_tool(**kwargs))
def _normalize_skills(values):
normalized = []
for value in values:
text = str(value or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _parse_flags(tokens):
opts = {
"name": None,
"deliver": None,
"repeat": None,
"skills": [],
"add_skills": [],
"remove_skills": [],
"clear_skills": False,
"all": False,
"prompt": None,
"schedule": None,
"positionals": [],
}
i = 0
while i < len(tokens):
token = tokens[i]
if token == "--name" and i + 1 < len(tokens):
opts["name"] = tokens[i + 1]
i += 2
elif token == "--deliver" and i + 1 < len(tokens):
opts["deliver"] = tokens[i + 1]
i += 2
elif token == "--repeat" and i + 1 < len(tokens):
try:
opts["repeat"] = int(tokens[i + 1])
except ValueError:
print("(._.) --repeat must be an integer")
return None
i += 2
elif token == "--skill" and i + 1 < len(tokens):
opts["skills"].append(tokens[i + 1])
i += 2
elif token == "--add-skill" and i + 1 < len(tokens):
opts["add_skills"].append(tokens[i + 1])
i += 2
elif token == "--remove-skill" and i + 1 < len(tokens):
opts["remove_skills"].append(tokens[i + 1])
i += 2
elif token == "--clear-skills":
opts["clear_skills"] = True
i += 1
elif token == "--all":
opts["all"] = True
i += 1
elif token == "--prompt" and i + 1 < len(tokens):
opts["prompt"] = tokens[i + 1]
i += 2
elif token == "--schedule" and i + 1 < len(tokens):
opts["schedule"] = tokens[i + 1]
i += 2
else:
opts["positionals"].append(token)
i += 1
return opts
tokens = shlex.split(cmd)
if len(tokens) == 1:
print()
print("+" + "-" * 60 + "+")
print("|" + " " * 18 + "(^_^) Scheduled Tasks" + " " * 19 + "|")
print("+" + "-" * 60 + "+")
print("+" + "-" * 68 + "+")
print("|" + " " * 22 + "(^_^) Scheduled Tasks" + " " * 23 + "|")
print("+" + "-" * 68 + "+")
print()
print(" Commands:")
print(" /cron - List scheduled jobs")
print(" /cron list - List scheduled jobs")
print(' /cron add <schedule> <prompt> - Add a new job')
print(" /cron pause <job_id> - Pause a job")
print(" /cron resume <job_id> - Resume a job")
print(" /cron run <job_id> - Run a job on the next tick")
print(" /cron remove <job_id> - Remove a job")
print(" /cron list")
print(' /cron add "every 2h" "Check server status" [--skill blogwatcher]')
print(' /cron edit <job_id> --schedule "every 4h" --prompt "New task"')
print(" /cron edit <job_id> --skill blogwatcher --skill find-nearby")
print(" /cron edit <job_id> --remove-skill blogwatcher")
print(" /cron edit <job_id> --clear-skills")
print(" /cron pause <job_id>")
print(" /cron resume <job_id>")
print(" /cron run <job_id>")
print(" /cron remove <job_id>")
print()
print(" Schedule formats:")
print(" 30m, 2h, 1d - One-shot delay")
print(' "every 30m", "every 2h" - Recurring interval')
print(' "0 9 * * *" - Cron expression')
print()
# Show current jobs
jobs = list_jobs()
result = _cron_api(action="list")
jobs = result.get("jobs", []) if result.get("success") else []
if jobs:
print(" Current Jobs:")
print(" " + "-" * 55)
print(" " + "-" * 63)
for job in jobs:
# Format repeat status
times = job["repeat"].get("times")
completed = job["repeat"].get("completed", 0)
if times is None:
repeat_str = "forever"
else:
repeat_str = f"{completed}/{times}"
print(f" {job['id'][:12]:<12} | {job['schedule_display']:<15} | {repeat_str:<8}")
prompt_preview = job['prompt'][:45] + "..." if len(job['prompt']) > 45 else job['prompt']
print(f" {prompt_preview}")
repeat_str = job.get("repeat", "?")
print(f" {job['job_id'][:12]:<12} | {job['schedule']:<15} | {repeat_str:<8}")
if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
print(f" {job.get('prompt_preview', '')}")
if job.get("next_run_at"):
from datetime import datetime
next_run = datetime.fromisoformat(job["next_run_at"])
print(f" Next: {next_run.strftime('%Y-%m-%d %H:%M')}")
print(f" Next: {job['next_run_at']}")
print()
else:
print(" No scheduled jobs. Use '/cron add' to create one.")
print()
return
subcommand = parts[1].lower()
subcommand = tokens[1].lower()
opts = _parse_flags(tokens[2:])
if opts is None:
return
if subcommand == "list":
# /cron list - just show jobs
jobs = list_jobs()
result = _cron_api(action="list", include_disabled=opts["all"])
jobs = result.get("jobs", []) if result.get("success") else []
if not jobs:
print("(._.) No scheduled jobs.")
return
print()
print("Scheduled Jobs:")
print("-" * 70)
print("-" * 80)
for job in jobs:
times = job["repeat"].get("times")
completed = job["repeat"].get("completed", 0)
repeat_str = "forever" if times is None else f"{completed}/{times}"
print(f" ID: {job['id']}")
print(f" ID: {job['job_id']}")
print(f" Name: {job['name']}")
print(f" Schedule: {job['schedule_display']} ({repeat_str})")
print(f" State: {job.get('state', '?')}")
print(f" Schedule: {job['schedule']} ({job.get('repeat', '?')})")
print(f" Next run: {job.get('next_run_at', 'N/A')}")
print(f" Prompt: {job['prompt'][:80]}{'...' if len(job['prompt']) > 80 else ''}")
if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
print(f" Prompt: {job.get('prompt_preview', '')}")
if job.get("last_run_at"):
print(f" Last run: {job['last_run_at']} ({job.get('last_status', '?')})")
print()
elif subcommand == "add":
# /cron add <schedule> <prompt>
if len(parts) < 3:
return
if subcommand in {"add", "create"}:
positionals = opts["positionals"]
if not positionals:
print("(._.) Usage: /cron add <schedule> <prompt>")
print(" Example: /cron add 30m Remind me to take a break")
print(' Example: /cron add "every 2h" Check server status at 192.168.1.1')
return
# Parse schedule and prompt
rest = parts[2].strip()
# Handle quoted schedule (e.g., "every 30m" or "0 9 * * *")
if rest.startswith('"'):
# Find closing quote
close_quote = rest.find('"', 1)
if close_quote == -1:
print("(._.) Unmatched quote in schedule")
return
schedule = rest[1:close_quote]
prompt = rest[close_quote + 1:].strip()
schedule = opts["schedule"] or positionals[0]
prompt = opts["prompt"] or " ".join(positionals[1:])
skills = _normalize_skills(opts["skills"])
if not prompt and not skills:
print("(._.) Please provide a prompt or at least one skill")
return
result = _cron_api(
action="create",
schedule=schedule,
prompt=prompt or None,
name=opts["name"],
deliver=opts["deliver"],
repeat=opts["repeat"],
skills=skills or None,
)
if result.get("success"):
print(f"(^_^)b Created job: {result['job_id']}")
print(f" Schedule: {result['schedule']}")
if result.get("skills"):
print(f" Skills: {', '.join(result['skills'])}")
print(f" Next run: {result['next_run_at']}")
else:
# First word is schedule
schedule_parts = rest.split(maxsplit=1)
schedule = schedule_parts[0]
prompt = schedule_parts[1] if len(schedule_parts) > 1 else ""
if not prompt:
print("(._.) Please provide a prompt for the job")
return
try:
job = create_job(prompt=prompt, schedule=schedule)
print(f"(^_^)b Created job: {job['id']}")
print(f" Schedule: {job['schedule_display']}")
print(f" Next run: {job['next_run_at']}")
except Exception as e:
print(f"(x_x) Failed to create job: {e}")
elif subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
if len(parts) < 3:
print(f"(._.) Usage: /cron {subcommand} <job_id>")
return
print(f"(x_x) Failed to create job: {result.get('error')}")
return
job_id = parts[2].strip()
job = get_job(job_id)
if not job:
if subcommand == "edit":
positionals = opts["positionals"]
if not positionals:
print("(._.) Usage: /cron edit <job_id> [--schedule ...] [--prompt ...] [--skill ...]")
return
job_id = positionals[0]
existing = get_job(job_id)
if not existing:
print(f"(._.) Job not found: {job_id}")
return
if subcommand == "pause":
updated = pause_job(job_id, reason="paused from /cron")
if updated:
print(f"(^_^)b Paused job: {updated['name']} ({job_id})")
else:
print(f"(x_x) Failed to pause job: {job_id}")
elif subcommand == "resume":
updated = resume_job(job_id)
if updated:
print(f"(^_^)b Resumed job: {updated['name']} ({job_id})")
print(f" Next run: {updated.get('next_run_at')}")
else:
print(f"(x_x) Failed to resume job: {job_id}")
elif subcommand == "run":
updated = trigger_job(job_id)
if updated:
print(f"(^_^)b Triggered job: {updated['name']} ({job_id})")
print(" It will run on the next scheduler tick.")
else:
print(f"(x_x) Failed to trigger job: {job_id}")
else:
if remove_job(job_id):
print(f"(^_^)b Removed job: {job['name']} ({job_id})")
else:
print(f"(x_x) Failed to remove job: {job_id}")
final_skills = None
replacement_skills = _normalize_skills(opts["skills"])
add_skills = _normalize_skills(opts["add_skills"])
remove_skills = set(_normalize_skills(opts["remove_skills"]))
existing_skills = list(existing.get("skills") or ([] if not existing.get("skill") else [existing.get("skill")]))
if opts["clear_skills"]:
final_skills = []
elif replacement_skills:
final_skills = replacement_skills
elif add_skills or remove_skills:
final_skills = [skill for skill in existing_skills if skill not in remove_skills]
for skill in add_skills:
if skill not in final_skills:
final_skills.append(skill)
else:
print(f"(._.) Unknown cron command: {subcommand}")
print(" Available: list, add, pause, resume, run, remove")
result = _cron_api(
action="update",
job_id=job_id,
schedule=opts["schedule"],
prompt=opts["prompt"],
name=opts["name"],
deliver=opts["deliver"],
repeat=opts["repeat"],
skills=final_skills,
)
if result.get("success"):
job = result["job"]
print(f"(^_^)b Updated job: {job['job_id']}")
print(f" Schedule: {job['schedule']}")
if job.get("skills"):
print(f" Skills: {', '.join(job['skills'])}")
else:
print(" Skills: none")
else:
print(f"(x_x) Failed to update job: {result.get('error')}")
return
if subcommand in {"pause", "resume", "run", "remove", "rm", "delete"}:
positionals = opts["positionals"]
if not positionals:
print(f"(._.) Usage: /cron {subcommand} <job_id>")
return
job_id = positionals[0]
action = "remove" if subcommand in {"remove", "rm", "delete"} else subcommand
result = _cron_api(action=action, job_id=job_id, reason="paused from /cron" if action == "pause" else None)
if not result.get("success"):
print(f"(x_x) Failed to {action} job: {result.get('error')}")
return
if action == "pause":
print(f"(^_^)b Paused job: {result['job']['name']} ({job_id})")
elif action == "resume":
print(f"(^_^)b Resumed job: {result['job']['name']} ({job_id})")
print(f" Next run: {result['job'].get('next_run_at')}")
elif action == "run":
print(f"(^_^)b Triggered job: {result['job']['name']} ({job_id})")
print(" It will run on the next scheduler tick.")
else:
removed = result.get("removed_job", {})
print(f"(^_^)b Removed job: {removed.get('name', job_id)} ({job_id})")
return
print(f"(._.) Unknown cron command: {subcommand}")
print(" Available: list, add, edit, pause, resume, run, remove")
def _handle_skills_command(self, cmd: str):
"""Handle /skills slash command — delegates to hermes_cli.skills_hub."""

View file

@ -32,6 +32,32 @@ JOBS_FILE = CRON_DIR / "jobs.json"
OUTPUT_DIR = CRON_DIR / "output"
def _normalize_skill_list(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
"""Normalize legacy/single-skill and multi-skill inputs into a unique ordered list."""
if skills is None:
raw_items = [skill] if skill else []
elif isinstance(skills, str):
raw_items = [skills]
else:
raw_items = list(skills)
normalized: List[str] = []
for item in raw_items:
text = str(item or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _apply_skill_fields(job: Dict[str, Any]) -> Dict[str, Any]:
"""Return a job dict with canonical `skills` and legacy `skill` fields aligned."""
normalized = dict(job)
skills = _normalize_skill_list(normalized.get("skill"), normalized.get("skills"))
normalized["skills"] = skills
normalized["skill"] = skills[0] if skills else None
return normalized
def _secure_dir(path: Path):
"""Set directory to owner-only access (0700). No-op on Windows."""
try:
@ -265,6 +291,7 @@ def create_job(
deliver: Optional[str] = None,
origin: Optional[Dict[str, Any]] = None,
skill: Optional[str] = None,
skills: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Create a new cron job.
@ -276,7 +303,8 @@ def create_job(
repeat: How many times to run (None = forever, 1 = once)
deliver: Where to deliver output ("origin", "local", "telegram", etc.)
origin: Source info where job was created (for "origin" delivery)
skill: Optional skill name to load before running the prompt
skill: Optional legacy single skill name to load before running the prompt
skills: Optional ordered list of skills to load before running the prompt
Returns:
The created job dict
@ -294,12 +322,14 @@ def create_job(
job_id = uuid.uuid4().hex[:12]
now = _hermes_now().isoformat()
label_source = skill or prompt or "cron job"
normalized_skills = _normalize_skill_list(skill, skills)
label_source = (normalized_skills[0] if normalized_skills else prompt) or "cron job"
job = {
"id": job_id,
"name": name or label_source[:50].strip(),
"prompt": prompt,
"skill": skill,
"skills": normalized_skills,
"skill": normalized_skills[0] if normalized_skills else None,
"schedule": parsed_schedule,
"schedule_display": parsed_schedule.get("display", schedule),
"repeat": {
@ -332,13 +362,13 @@ def get_job(job_id: str) -> Optional[Dict[str, Any]]:
jobs = load_jobs()
for job in jobs:
if job["id"] == job_id:
return job
return _apply_skill_fields(job)
return None
def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
"""List all jobs, optionally including disabled ones."""
jobs = load_jobs()
jobs = [_apply_skill_fields(j) for j in load_jobs()]
if not include_disabled:
jobs = [j for j in jobs if j.get("enabled", True)]
return jobs
@ -351,9 +381,14 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
if job["id"] != job_id:
continue
updated = {**job, **updates}
updated = _apply_skill_fields({**job, **updates})
schedule_changed = "schedule" in updates
if "skills" in updates or "skill" in updates:
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
updated["skills"] = normalized_skills
updated["skill"] = normalized_skills[0] if normalized_skills else None
if schedule_changed:
updated_schedule = updated["schedule"]
updated["schedule_display"] = updates.get(
@ -368,7 +403,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
jobs[i] = updated
save_jobs(jobs)
return jobs[i]
return _apply_skill_fields(jobs[i])
return None
@ -479,21 +514,21 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):
def get_due_jobs() -> List[Dict[str, Any]]:
"""Get all jobs that are due to run now."""
now = _hermes_now()
jobs = load_jobs()
jobs = [_apply_skill_fields(j) for j in load_jobs()]
due = []
for job in jobs:
if not job.get("enabled", True):
continue
next_run = job.get("next_run_at")
if not next_run:
continue
next_run_dt = _ensure_aware(datetime.fromisoformat(next_run))
if next_run_dt <= now:
due.append(job)
return due

View file

@ -149,25 +149,37 @@ def _deliver_result(job: dict, content: str) -> None:
def _build_job_prompt(job: dict) -> str:
"""Build the effective prompt for a cron job, optionally loading a skill first."""
"""Build the effective prompt for a cron job, optionally loading one or more skills first."""
prompt = job.get("prompt", "")
skill_name = job.get("skill")
if not skill_name:
skills = job.get("skills")
if skills is None:
legacy = job.get("skill")
skills = [legacy] if legacy else []
skill_names = [str(name).strip() for name in skills if str(name).strip()]
if not skill_names:
return prompt
from tools.skills_tool import skill_view
loaded = json.loads(skill_view(skill_name))
if not loaded.get("success"):
error = loaded.get("error") or f"Failed to load skill '{skill_name}'"
raise RuntimeError(error)
parts = []
for skill_name in skill_names:
loaded = json.loads(skill_view(skill_name))
if not loaded.get("success"):
error = loaded.get("error") or f"Failed to load skill '{skill_name}'"
raise RuntimeError(error)
content = str(loaded.get("content") or "").strip()
if parts:
parts.append("")
parts.extend(
[
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
"",
content,
]
)
content = str(loaded.get("content") or "").strip()
parts = [
f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]',
"",
content,
]
if prompt:
parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"])
return "\n".join(parts)

View file

@ -315,7 +315,7 @@ def build_delivery_context_for_tool(
origin: Optional[SessionSource] = None
) -> Dict[str, Any]:
"""
Build context for the schedule_cronjob tool to understand delivery options.
Build context for the unified cronjob tool to understand delivery options.
This is passed to the tool so it can validate and explain delivery targets.
"""

View file

@ -1,15 +1,14 @@
"""
Cron subcommand for hermes CLI.
Handles: hermes cron [list|status|tick]
Cronjobs are executed automatically by the gateway daemon (hermes gateway).
Install the gateway as a service for background execution:
hermes gateway install
Handles standalone cron management commands like list, create, edit,
pause/resume/run/remove, status, and tick.
"""
import json
import sys
from pathlib import Path
from typing import Iterable, List, Optional
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(PROJECT_ROOT))
@ -17,58 +16,82 @@ sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli.colors import Colors, color
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
if skills is None:
if single_skill is None:
return None
raw_items = [single_skill]
else:
raw_items = list(skills)
normalized: List[str] = []
for item in raw_items:
text = str(item or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _cron_api(**kwargs):
from tools.cronjob_tools import cronjob as cronjob_tool
return json.loads(cronjob_tool(**kwargs))
def cron_list(show_all: bool = False):
"""List all scheduled jobs."""
from cron.jobs import list_jobs
jobs = list_jobs(include_disabled=show_all)
if not jobs:
print(color("No scheduled jobs.", Colors.DIM))
print(color("Create one with the /cron add command in chat, or via Telegram.", Colors.DIM))
print(color("Create one with 'hermes cron create ...' or the /cron command in chat.", Colors.DIM))
return
print()
print(color("┌─────────────────────────────────────────────────────────────────────────┐", Colors.CYAN))
print(color("│ Scheduled Jobs │", Colors.CYAN))
print(color("└─────────────────────────────────────────────────────────────────────────┘", Colors.CYAN))
print()
for job in jobs:
job_id = job.get("id", "?")[:8]
name = job.get("name", "(unnamed)")
schedule = job.get("schedule_display", job.get("schedule", {}).get("value", "?"))
enabled = job.get("enabled", True)
state = job.get("state", "scheduled" if job.get("enabled", True) else "paused")
next_run = job.get("next_run_at", "?")
repeat_info = job.get("repeat", {})
repeat_times = repeat_info.get("times")
repeat_completed = repeat_info.get("completed", 0)
if repeat_times:
repeat_str = f"{repeat_completed}/{repeat_times}"
else:
repeat_str = ""
repeat_str = f"{repeat_completed}/{repeat_times}" if repeat_times else ""
deliver = job.get("deliver", ["local"])
if isinstance(deliver, str):
deliver = [deliver]
deliver_str = ", ".join(deliver)
if not enabled:
status = color("[disabled]", Colors.RED)
else:
skills = job.get("skills") or ([job["skill"]] if job.get("skill") else [])
if state == "paused":
status = color("[paused]", Colors.YELLOW)
elif state == "completed":
status = color("[completed]", Colors.BLUE)
elif job.get("enabled", True):
status = color("[active]", Colors.GREEN)
else:
status = color("[disabled]", Colors.RED)
print(f" {color(job_id, Colors.YELLOW)} {status}")
print(f" Name: {name}")
print(f" Schedule: {schedule}")
print(f" Repeat: {repeat_str}")
print(f" Next run: {next_run}")
print(f" Deliver: {deliver_str}")
if skills:
print(f" Skills: {', '.join(skills)}")
print()
# Warn if gateway isn't running
from hermes_cli.gateway import find_gateway_pids
if not find_gateway_pids():
print(color(" ⚠ Gateway is not running — jobs won't fire automatically.", Colors.YELLOW))
@ -86,9 +109,9 @@ def cron_status():
"""Show cron execution status."""
from cron.jobs import list_jobs
from hermes_cli.gateway import find_gateway_pids
print()
pids = find_gateway_pids()
if pids:
print(color("✓ Gateway is running — cron jobs will fire automatically", Colors.GREEN))
@ -99,9 +122,9 @@ def cron_status():
print(" To enable automatic execution:")
print(" hermes gateway install # Install as system service (recommended)")
print(" hermes gateway # Or run in foreground")
print()
jobs = list_jobs(include_disabled=False)
if jobs:
next_runs = [j.get("next_run_at") for j in jobs if j.get("next_run_at")]
@ -110,25 +133,131 @@ def cron_status():
print(f" Next run: {min(next_runs)}")
else:
print(" No active jobs")
print()
def cron_create(args):
result = _cron_api(
action="create",
schedule=args.schedule,
prompt=args.prompt,
name=getattr(args, "name", None),
deliver=getattr(args, "deliver", None),
repeat=getattr(args, "repeat", None),
skill=getattr(args, "skill", None),
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
)
if not result.get("success"):
print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED))
return 1
print(color(f"Created job: {result['job_id']}", Colors.GREEN))
print(f" Name: {result['name']}")
print(f" Schedule: {result['schedule']}")
if result.get("skills"):
print(f" Skills: {', '.join(result['skills'])}")
print(f" Next run: {result['next_run_at']}")
return 0
def cron_edit(args):
from cron.jobs import get_job
job = get_job(args.job_id)
if not job:
print(color(f"Job not found: {args.job_id}", Colors.RED))
return 1
existing_skills = list(job.get("skills") or ([] if not job.get("skill") else [job.get("skill")]))
replacement_skills = _normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None))
add_skills = _normalize_skills(None, getattr(args, "add_skills", None)) or []
remove_skills = set(_normalize_skills(None, getattr(args, "remove_skills", None)) or [])
final_skills = None
if getattr(args, "clear_skills", False):
final_skills = []
elif replacement_skills is not None:
final_skills = replacement_skills
elif add_skills or remove_skills:
final_skills = [skill for skill in existing_skills if skill not in remove_skills]
for skill in add_skills:
if skill not in final_skills:
final_skills.append(skill)
result = _cron_api(
action="update",
job_id=args.job_id,
schedule=getattr(args, "schedule", None),
prompt=getattr(args, "prompt", None),
name=getattr(args, "name", None),
deliver=getattr(args, "deliver", None),
repeat=getattr(args, "repeat", None),
skills=final_skills,
)
if not result.get("success"):
print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED))
return 1
updated = result["job"]
print(color(f"Updated job: {updated['job_id']}", Colors.GREEN))
print(f" Name: {updated['name']}")
print(f" Schedule: {updated['schedule']}")
if updated.get("skills"):
print(f" Skills: {', '.join(updated['skills'])}")
else:
print(" Skills: none")
return 0
def _job_action(action: str, job_id: str, success_verb: str) -> int:
result = _cron_api(action=action, job_id=job_id)
if not result.get("success"):
print(color(f"Failed to {action} job: {result.get('error', 'unknown error')}", Colors.RED))
return 1
job = result.get("job") or result.get("removed_job") or {}
print(color(f"{success_verb} job: {job.get('name', job_id)} ({job_id})", Colors.GREEN))
if action in {"resume", "run"} and result.get("job", {}).get("next_run_at"):
print(f" Next run: {result['job']['next_run_at']}")
if action == "run":
print(" It will run on the next scheduler tick.")
return 0
def cron_command(args):
"""Handle cron subcommands."""
subcmd = getattr(args, 'cron_command', None)
if subcmd is None or subcmd == "list":
show_all = getattr(args, 'all', False)
cron_list(show_all)
elif subcmd == "tick":
cron_tick()
elif subcmd == "status":
return 0
if subcmd == "status":
cron_status()
else:
print(f"Unknown cron command: {subcmd}")
print("Usage: hermes cron [list|status|tick]")
sys.exit(1)
return 0
if subcmd == "tick":
cron_tick()
return 0
if subcmd in {"create", "add"}:
return cron_create(args)
if subcmd == "edit":
return cron_edit(args)
if subcmd == "pause":
return _job_action("pause", args.job_id, "Paused")
if subcmd == "resume":
return _job_action("resume", args.job_id, "Resumed")
if subcmd == "run":
return _job_action("run", args.job_id, "Triggered")
if subcmd in {"remove", "rm", "delete"}:
return _job_action("remove", args.job_id, "Removed")
print(f"Unknown cron command: {subcmd}")
print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|tick]")
sys.exit(1)

View file

@ -2589,13 +2589,48 @@ For more help on a command:
# cron list
cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs")
cron_list.add_argument("--all", action="store_true", help="Include disabled jobs")
# cron create/add
cron_create = cron_subparsers.add_parser("create", aliases=["add"], help="Create a scheduled job")
cron_create.add_argument("schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'")
cron_create.add_argument("prompt", nargs="?", help="Optional self-contained prompt or task instruction")
cron_create.add_argument("--name", help="Optional human-friendly job name")
cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id")
cron_create.add_argument("--repeat", type=int, help="Optional repeat count")
cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.")
# cron edit
cron_edit = cron_subparsers.add_parser("edit", help="Edit an existing scheduled job")
cron_edit.add_argument("job_id", help="Job ID to edit")
cron_edit.add_argument("--schedule", help="New schedule")
cron_edit.add_argument("--prompt", help="New prompt/task instruction")
cron_edit.add_argument("--name", help="New job name")
cron_edit.add_argument("--deliver", help="New delivery target")
cron_edit.add_argument("--repeat", type=int, help="New repeat count")
cron_edit.add_argument("--skill", dest="skills", action="append", help="Replace the job's skills with this set. Repeat to attach multiple skills.")
cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.")
cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.")
cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job")
# lifecycle actions
cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job")
cron_pause.add_argument("job_id", help="Job ID to pause")
cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job")
cron_resume.add_argument("job_id", help="Job ID to resume")
cron_run = cron_subparsers.add_parser("run", help="Run a job on the next scheduler tick")
cron_run.add_argument("job_id", help="Job ID to trigger")
cron_remove = cron_subparsers.add_parser("remove", aliases=["rm", "delete"], help="Remove a scheduled job")
cron_remove.add_argument("job_id", help="Job ID to remove")
# cron status
cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
# cron tick (mostly for debugging)
cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
cron_parser.set_defaults(func=cmd_cron)
# =========================================================================

View file

@ -91,7 +91,7 @@ CONFIGURABLE_TOOLSETS = [
("session_search", "🔎 Session Search", "search past conversations"),
("clarify", "❓ Clarifying Questions", "clarify"),
("delegation", "👥 Task Delegation", "delegate_task"),
("cronjob", "⏰ Cron Jobs", "create, list, update, pause, resume, remove, run"),
("cronjob", "⏰ Cron Jobs", "create/list/update/pause/resume/run, with optional attached skills"),
("rl", "🧪 RL Training", "Tinker-Atropos training tools"),
("homeassistant", "🏠 Home Assistant", "smart home device control"),
]

View file

@ -248,3 +248,49 @@ class TestRunJobSkillBacked:
assert "blogwatcher" in prompt_arg
assert "Follow this skill" in prompt_arg
assert "Check the feeds and summarize anything new." in prompt_arg
def test_run_job_loads_multiple_skills_in_order(self, tmp_path):
job = {
"id": "multi-skill-job",
"name": "multi skill test",
"prompt": "Combine the results.",
"skills": ["blogwatcher", "find-nearby"],
}
fake_db = MagicMock()
def _skill_view(name):
return json.dumps({"success": True, "content": f"# {name}\nInstructions for {name}."})
with patch("cron.scheduler._hermes_home", tmp_path), \
patch("cron.scheduler._resolve_origin", return_value=None), \
patch("dotenv.load_dotenv"), \
patch("hermes_state.SessionDB", return_value=fake_db), \
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "***",
"base_url": "https://example.invalid/v1",
"provider": "openrouter",
"api_mode": "chat_completions",
},
), \
patch("tools.skills_tool.skill_view", side_effect=_skill_view) as skill_view_mock, \
patch("run_agent.AIAgent") as mock_agent_cls:
mock_agent = MagicMock()
mock_agent.run_conversation.return_value = {"final_response": "ok"}
mock_agent_cls.return_value = mock_agent
success, output, final_response, error = run_job(job)
assert success is True
assert error is None
assert final_response == "ok"
assert skill_view_mock.call_count == 2
assert [call.args[0] for call in skill_view_mock.call_args_list] == ["blogwatcher", "find-nearby"]
prompt_arg = mock_agent.run_conversation.call_args.args[0]
assert prompt_arg.index("blogwatcher") < prompt_arg.index("find-nearby")
assert "Instructions for blogwatcher." in prompt_arg
assert "Instructions for find-nearby." in prompt_arg
assert "Combine the results." in prompt_arg

View file

@ -0,0 +1,107 @@
"""Tests for hermes_cli.cron command handling."""
from argparse import Namespace
import pytest
from cron.jobs import create_job, get_job, list_jobs
from hermes_cli.cron import cron_command
@pytest.fixture()
def tmp_cron_dir(tmp_path, monkeypatch):
monkeypatch.setattr("cron.jobs.CRON_DIR", tmp_path / "cron")
monkeypatch.setattr("cron.jobs.JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr("cron.jobs.OUTPUT_DIR", tmp_path / "cron" / "output")
return tmp_path
class TestCronCommandLifecycle:
def test_pause_resume_run(self, tmp_cron_dir, capsys):
job = create_job(prompt="Check server status", schedule="every 1h")
cron_command(Namespace(cron_command="pause", job_id=job["id"]))
paused = get_job(job["id"])
assert paused["state"] == "paused"
cron_command(Namespace(cron_command="resume", job_id=job["id"]))
resumed = get_job(job["id"])
assert resumed["state"] == "scheduled"
cron_command(Namespace(cron_command="run", job_id=job["id"]))
triggered = get_job(job["id"])
assert triggered["state"] == "scheduled"
out = capsys.readouterr().out
assert "Paused job" in out
assert "Resumed job" in out
assert "Triggered job" in out
def test_edit_can_replace_and_clear_skills(self, tmp_cron_dir, capsys):
job = create_job(
prompt="Combine skill outputs",
schedule="every 1h",
skill="blogwatcher",
)
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule="every 2h",
prompt="Revised prompt",
name="Edited Job",
deliver=None,
repeat=None,
skill=None,
skills=["find-nearby", "blogwatcher"],
clear_skills=False,
)
)
updated = get_job(job["id"])
assert updated["skills"] == ["find-nearby", "blogwatcher"]
assert updated["name"] == "Edited Job"
assert updated["prompt"] == "Revised prompt"
assert updated["schedule_display"] == "every 120m"
cron_command(
Namespace(
cron_command="edit",
job_id=job["id"],
schedule=None,
prompt=None,
name=None,
deliver=None,
repeat=None,
skill=None,
skills=None,
clear_skills=True,
)
)
cleared = get_job(job["id"])
assert cleared["skills"] == []
assert cleared["skill"] is None
out = capsys.readouterr().out
assert "Updated job" in out
def test_create_with_multiple_skills(self, tmp_cron_dir, capsys):
cron_command(
Namespace(
cron_command="create",
schedule="every 1h",
prompt="Use both skills",
name="Skill combo",
deliver=None,
repeat=None,
skill=None,
skills=["blogwatcher", "find-nearby"],
)
)
out = capsys.readouterr().out
assert "Created job" in out
jobs = list_jobs()
assert len(jobs) == 1
assert jobs[0]["skills"] == ["blogwatcher", "find-nearby"]
assert jobs[0]["name"] == "Skill combo"

View file

@ -245,3 +245,35 @@ class TestUnifiedCronjobTool:
listing = json.loads(cronjob(action="list"))
assert listing["jobs"][0]["skill"] == "blogwatcher"
def test_create_multi_skill_job(self):
result = json.loads(
cronjob(
action="create",
skills=["blogwatcher", "find-nearby"],
prompt="Use both skills and combine the result.",
schedule="every 1h",
name="Combo job",
)
)
assert result["success"] is True
assert result["skills"] == ["blogwatcher", "find-nearby"]
listing = json.loads(cronjob(action="list"))
assert listing["jobs"][0]["skills"] == ["blogwatcher", "find-nearby"]
def test_update_can_clear_skills(self):
created = json.loads(
cronjob(
action="create",
skills=["blogwatcher", "find-nearby"],
prompt="Use both skills and combine the result.",
schedule="every 1h",
)
)
updated = json.loads(
cronjob(action="update", job_id=created["job_id"], skills=[])
)
assert updated["success"] is True
assert updated["job"]["skills"] == []
assert updated["job"]["skill"] is None

View file

@ -10,7 +10,7 @@ import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
# Import from cron module (will be available when properly installed)
sys.path.insert(0, str(Path(__file__).parent.parent))
@ -85,12 +85,31 @@ def _repeat_display(job: Dict[str, Any]) -> str:
return f"{completed}/{times}" if completed else f"{times} times"
def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) -> List[str]:
if skills is None:
raw_items = [skill] if skill else []
elif isinstance(skills, str):
raw_items = [skills]
else:
raw_items = list(skills)
normalized: List[str] = []
for item in raw_items:
text = str(item or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
prompt = job.get("prompt", "")
skills = _canonical_skills(job.get("skill"), job.get("skills"))
return {
"job_id": job["id"],
"name": job["name"],
"skill": job.get("skill"),
"skill": skills[0] if skills else None,
"skills": skills,
"prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt,
"schedule": job.get("schedule_display"),
"repeat": _repeat_display(job),
@ -115,6 +134,7 @@ def cronjob(
deliver: Optional[str] = None,
include_disabled: bool = False,
skill: Optional[str] = None,
skills: Optional[List[str]] = None,
reason: Optional[str] = None,
task_id: str = None,
) -> str:
@ -127,8 +147,9 @@ def cronjob(
if normalized == "create":
if not schedule:
return json.dumps({"success": False, "error": "schedule is required for create"}, indent=2)
if not prompt and not skill:
return json.dumps({"success": False, "error": "create requires either prompt or skill"}, indent=2)
canonical_skills = _canonical_skills(skill, skills)
if not prompt and not canonical_skills:
return json.dumps({"success": False, "error": "create requires either prompt or at least one skill"}, indent=2)
if prompt:
scan_error = _scan_cron_prompt(prompt)
if scan_error:
@ -141,7 +162,7 @@ def cronjob(
repeat=repeat,
deliver=deliver,
origin=_origin_from_env(),
skill=skill,
skills=canonical_skills,
)
return json.dumps(
{
@ -149,6 +170,7 @@ def cronjob(
"job_id": job["id"],
"name": job["name"],
"skill": job.get("skill"),
"skills": job.get("skills", []),
"schedule": job["schedule_display"],
"repeat": _repeat_display(job),
"deliver": job.get("deliver", "local"),
@ -213,8 +235,10 @@ def cronjob(
updates["name"] = name
if deliver is not None:
updates["deliver"] = deliver
if skill is not None:
updates["skill"] = skill
if skills is not None or skill is not None:
canonical_skills = _canonical_skills(skill, skills)
updates["skills"] = canonical_skills
updates["skill"] = canonical_skills[0] if canonical_skills else None
if repeat is not None:
repeat_state = dict(job.get("repeat") or {})
repeat_state["times"] = repeat
@ -272,12 +296,13 @@ CRONJOB_SCHEMA = {
"name": "cronjob",
"description": """Manage scheduled cron jobs with a single compressed tool.
Use action='create' to schedule a new job from a prompt or a skill.
Use action='create' to schedule a new job from a prompt or one or more skills.
Use action='list' to inspect jobs.
Use action='update', 'pause', 'resume', 'remove', or 'run' to manage an existing job.
Jobs run in a fresh session with no current-chat context, so prompts must be self-contained.
If skill is provided on create, the future cron run loads that skill first, then follows the prompt as the task instruction.
If skill or skills are provided on create, the future cron run loads those skills in order, then follows the prompt as the task instruction.
On update, passing skills=[] clears attached skills.
Important safety rule: cron-run sessions should not recursively schedule more cron jobs.""",
"parameters": {
@ -293,7 +318,7 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
},
"prompt": {
"type": "string",
"description": "For create: the full self-contained prompt. If skill is also provided, this becomes the task instruction paired with that skill."
"description": "For create: the full self-contained prompt. If skill or skills are also provided, this becomes the task instruction paired with those skills."
},
"schedule": {
"type": "string",
@ -317,7 +342,12 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
},
"skill": {
"type": "string",
"description": "Optional skill name to load before executing the cron prompt"
"description": "Optional single skill name to load before executing the cron prompt"
},
"skills": {
"type": "array",
"items": {"type": "string"},
"description": "Optional ordered list of skills to load before executing the cron prompt. On update, pass an empty array to clear attached skills."
},
"reason": {
"type": "string",
@ -365,6 +395,7 @@ registry.register(
deliver=args.get("deliver"),
include_disabled=args.get("include_disabled", False),
skill=args.get("skill"),
skills=args.get("skills"),
reason=args.get("reason"),
task_id=kw.get("task_id"),
),

View file

@ -1,7 +1,7 @@
---
sidebar_position: 11
title: "Cron Internals"
description: "How Hermes stores, schedules, locks, and delivers cron jobs"
description: "How Hermes stores, schedules, edits, pauses, skill-loads, and delivers cron jobs"
---
# Cron Internals
@ -10,7 +10,9 @@ Hermes cron support is implemented primarily in:
- `cron/jobs.py`
- `cron/scheduler.py`
- `tools/cronjob_tools.py`
- `gateway/run.py`
- `hermes_cli/cron.py`
## Scheduling model
@ -21,9 +23,30 @@ Hermes supports:
- cron expressions
- explicit timestamps
The model-facing surface is a single `cronjob` tool with action-style operations:
- `create`
- `list`
- `update`
- `pause`
- `resume`
- `run`
- `remove`
## Job storage
Cron jobs are stored in Hermes-managed local state with atomic save/update semantics.
Cron jobs are stored in Hermes-managed local state (`~/.hermes/cron/jobs.json`) with atomic write semantics.
Each job can carry:
- prompt
- schedule metadata
- repeat counters
- delivery target
- lifecycle state (`scheduled`, `paused`, `completed`, etc.)
- zero, one, or multiple attached skills
Backward compatibility is preserved for older jobs that only stored a legacy single `skill` field or none of the newer lifecycle fields.
## Runtime behavior
@ -32,11 +55,22 @@ The scheduler:
- loads jobs
- computes due work
- executes jobs in fresh agent sessions
- optionally injects one or more skills before the prompt
- handles repeat counters
- updates next-run metadata
- updates next-run metadata and state
In gateway mode, cron ticking is integrated into the long-running gateway loop.
## Skill-backed jobs
A cron job may attach multiple skills. At runtime, Hermes loads those skills in order and then appends the job prompt as the task instruction.
This gives scheduled jobs reusable guidance without requiring the user to paste full skill bodies into the cron prompt.
## Recursion guard
Cron-run sessions disable the `cronjob` toolset. This prevents a scheduled job from recursively creating or mutating more cron jobs and accidentally exploding token usage or scheduler load.
## Delivery model
Cron jobs can deliver to:
@ -48,7 +82,7 @@ Cron jobs can deliver to:
## Locking
Hermes uses lock-based protections so concurrent cron ticks or overlapping scheduler processes do not corrupt job state.
Hermes uses lock-based protections so overlapping scheduler ticks do not execute the same due-job batch twice.
## Related docs

View file

@ -99,7 +99,7 @@ and open source LLMs. Summarize the top 3 stories in a concise briefing
with links. Use a friendly, professional tone. Deliver to telegram.
```
Hermes will create the cron job for you using the `schedule_cronjob` tool.
Hermes will create the cron job for you using the unified `cronjob` tool.
### Option B: CLI Slash Command
@ -232,7 +232,7 @@ Or ask conversationally:
Remove my morning briefing cron job.
```
Hermes will use `list_cronjobs` to find it and `remove_cronjob` to delete it.
Hermes will use `cronjob(action="list")` to find it and `cronjob(action="remove")` to delete it.
### Check Gateway Status

View file

@ -181,12 +181,18 @@ hermes status [--all] [--deep]
## `hermes cron`
```bash
hermes cron <list|status|tick>
hermes cron <list|create|edit|pause|resume|run|remove|status|tick>
```
| Subcommand | Description |
|------------|-------------|
| `list` | Show scheduled jobs. |
| `create` / `add` | Create a scheduled job from a prompt, optionally attaching one or more skills via repeated `--skill`. |
| `edit` | Update a job's schedule, prompt, name, delivery, repeat count, or attached skills. Supports `--clear-skills`, `--add-skill`, and `--remove-skill`. |
| `pause` | Pause a job without deleting it. |
| `resume` | Resume a paused job and compute its next future run. |
| `run` | Trigger a job on the next scheduler tick. |
| `remove` | Delete a scheduled job. |
| `status` | Check whether the cron scheduler is running. |
| `tick` | Run due jobs once and exit. |

View file

@ -40,9 +40,7 @@ This page documents the built-in Hermes tool registry as it exists in code. Avai
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `list_cronjobs` | List all scheduled cronjobs with their IDs, schedules, and status. Use this to: - See what jobs are currently scheduled - Find job IDs for removal with remove_cronjob - Check job status and next run times Returns job_id, name, schedule, re… | — |
| `remove_cronjob` | Remove a scheduled cronjob by its ID. Use list_cronjobs first to find the job_id of the job you want to remove. Jobs that have completed their repeat count are auto-removed, but you can use this to cancel a job before it completes. | — |
| `schedule_cronjob` | Schedule an automated task to run the agent on a schedule. ⚠️ CRITICAL: The cronjob runs in a FRESH SESSION with NO CONTEXT from this conversation. The prompt must be COMPLETELY SELF-CONTAINED with ALL necessary information including: - Fu… | — |
| `cronjob` | Unified scheduled-task manager. Use `action="create"`, `"list"`, `"update"`, `"pause"`, `"resume"`, `"run"`, or `"remove"` to manage jobs. Supports skill-backed jobs with one or more attached skills, and `skills=[]` on update clears attached skills. Cron runs happen in fresh sessions with no current-chat context. | — |
## `delegation` toolset

View file

@ -13,19 +13,19 @@ Toolsets are named bundles of tools that you can enable with `hermes chat --tool
| `browser` | core | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `web_search` |
| `clarify` | core | `clarify` |
| `code_execution` | core | `execute_code` |
| `cronjob` | core | `list_cronjobs`, `remove_cronjob`, `schedule_cronjob` |
| `cronjob` | core | `cronjob` |
| `debugging` | composite | `patch`, `process`, `read_file`, `search_files`, `terminal`, `web_extract`, `web_search`, `write_file` |
| `delegation` | core | `delegate_task` |
| `file` | core | `patch`, `read_file`, `search_files`, `write_file` |
| `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-discord` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-email` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-gateway` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-homeassistant` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-signal` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-slack` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-telegram` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-whatsapp` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `list_cronjobs`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `remove_cronjob`, `schedule_cronjob`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-cli` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-discord` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-email` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-gateway` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-homeassistant` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-signal` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-slack` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-telegram` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `hermes-whatsapp` | platform | `browser_back`, `browser_click`, `browser_close`, `browser_get_images`, `browser_navigate`, `browser_press`, `browser_scroll`, `browser_snapshot`, `browser_type`, `browser_vision`, `clarify`, `delegate_task`, `execute_code`, `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services`, `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`, `image_generate`, `cronjob`, `memory`, `mixture_of_agents`, `patch`, `process`, `read_file`, `search_files`, `send_message`, `session_search`, `skill_manage`, `skill_view`, `skills_list`, `terminal`, `text_to_speech`, `todo`, `vision_analyze`, `web_extract`, `web_search`, `write_file` |
| `homeassistant` | core | `ha_call_service`, `ha_get_state`, `ha_list_entities`, `ha_list_services` |
| `honcho` | core | `honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search` |
| `image_gen` | core | `image_generate` |

View file

@ -1,68 +1,183 @@
---
sidebar_position: 5
title: "Scheduled Tasks (Cron)"
description: "Schedule automated tasks with natural language — cron jobs, delivery options, and the gateway scheduler"
description: "Schedule automated tasks with natural language, manage them with one cron tool, and attach one or more skills"
---
# Scheduled Tasks (Cron)
Schedule tasks to run automatically with natural language or cron expressions. The agent can self-schedule using the `schedule_cronjob` tool from any platform.
Schedule tasks to run automatically with natural language or cron expressions. Hermes exposes cron management through a single `cronjob` tool with action-style operations instead of separate schedule/list/remove tools.
## Creating Scheduled Tasks
## What cron can do now
### In the CLI
Cron jobs can:
Use the `/cron` slash command:
- schedule one-shot or recurring tasks
- pause, resume, edit, trigger, and remove jobs
- attach zero, one, or multiple skills to a job
- deliver results back to the origin chat, local files, or configured platform targets
- run in fresh agent sessions with the normal static tool list
```
:::warning
Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops.
:::
## Creating scheduled tasks
### In chat with `/cron`
```bash
/cron add 30m "Remind me to check the build"
/cron add "every 2h" "Check server status"
/cron add "0 9 * * *" "Morning briefing"
/cron list
/cron remove <job_id>
/cron add "every 1h" "Summarize new feed items" --skill blogwatcher
/cron add "every 1h" "Use both skills and combine the result" --skill blogwatcher --skill find-nearby
```
### Through Natural Conversation
Simply ask the agent on any platform:
### From the standalone CLI
```bash
hermes cron create "every 2h" "Check server status"
hermes cron create "every 1h" "Summarize new feed items" --skill blogwatcher
hermes cron create "every 1h" "Use both skills and combine the result" \
--skill blogwatcher \
--skill find-nearby \
--name "Skill combo"
```
### Through natural conversation
Ask Hermes normally:
```text
Every morning at 9am, check Hacker News for AI news and send me a summary on Telegram.
```
The agent will use the `schedule_cronjob` tool to set it up.
Hermes will use the unified `cronjob` tool internally.
## How It Works
## Skill-backed cron jobs
**Cron execution is handled by the gateway daemon.** The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions:
A cron job can load one or more skills before it runs the prompt.
### Single skill
```python
cronjob(
action="create",
skill="blogwatcher",
prompt="Check the configured feeds and summarize anything new.",
schedule="0 9 * * *",
name="Morning feeds",
)
```
### Multiple skills
Skills are loaded in order. The prompt becomes the task instruction layered on top of those skills.
```python
cronjob(
action="create",
skills=["blogwatcher", "find-nearby"],
prompt="Look for new local events and interesting nearby places, then combine them into one short brief.",
schedule="every 6h",
name="Local brief",
)
```
This is useful when you want a scheduled agent to inherit reusable workflows without stuffing the full skill text into the cron prompt itself.
## Editing jobs
You do not need to delete and recreate jobs just to change them.
### Chat
```bash
/cron edit <job_id> --schedule "every 4h"
/cron edit <job_id> --prompt "Use the revised task"
/cron edit <job_id> --skill blogwatcher --skill find-nearby
/cron edit <job_id> --remove-skill blogwatcher
/cron edit <job_id> --clear-skills
```
### Standalone CLI
```bash
hermes cron edit <job_id> --schedule "every 4h"
hermes cron edit <job_id> --prompt "Use the revised task"
hermes cron edit <job_id> --skill blogwatcher --skill find-nearby
hermes cron edit <job_id> --add-skill find-nearby
hermes cron edit <job_id> --remove-skill blogwatcher
hermes cron edit <job_id> --clear-skills
```
Notes:
- repeated `--skill` replaces the job's attached skill list
- `--add-skill` appends to the existing list without replacing it
- `--remove-skill` removes specific attached skills
- `--clear-skills` removes all attached skills
## Lifecycle actions
Cron jobs now have a fuller lifecycle than just create/remove.
### Chat
```bash
/cron list
/cron pause <job_id>
/cron resume <job_id>
/cron run <job_id>
/cron remove <job_id>
```
### Standalone CLI
```bash
hermes cron list
hermes cron pause <job_id>
hermes cron resume <job_id>
hermes cron run <job_id>
hermes cron remove <job_id>
hermes cron status
hermes cron tick
```
What they do:
- `pause` — keep the job but stop scheduling it
- `resume` — re-enable the job and compute the next future run
- `run` — trigger the job on the next scheduler tick
- `remove` — delete it entirely
## How it works
**Cron execution is handled by the gateway daemon.** The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions.
```bash
hermes gateway install # Install as system service (recommended)
hermes gateway # Or run in foreground
hermes cron list # View scheduled jobs
hermes cron status # Check if gateway is running
hermes cron list
hermes cron status
```
### The Gateway Scheduler
### Gateway scheduler behavior
The scheduler runs as a background thread inside the gateway process. On each tick (every 60 seconds):
On each tick Hermes:
1. It loads all jobs from `~/.hermes/cron/jobs.json`
2. Checks each enabled job's `next_run_at` against the current time
3. For each due job, spawns a fresh `AIAgent` session with the job's prompt
4. The agent runs to completion with full tool access
5. The final response is delivered to the configured target
6. The job's run count is incremented and next run time computed
7. Jobs that hit their repeat limit are auto-removed
1. loads jobs from `~/.hermes/cron/jobs.json`
2. checks `next_run_at` against the current time
3. starts a fresh `AIAgent` session for each due job
4. optionally injects one or more attached skills into that fresh session
5. runs the prompt to completion
6. delivers the final response
7. updates run metadata and the next scheduled time
A **file-based lock** (`~/.hermes/cron/.tick.lock`) prevents duplicate execution if multiple processes overlap (e.g., gateway + manual tick).
A file lock at `~/.hermes/cron/.tick.lock` prevents overlapping scheduler ticks from double-running the same job batch.
:::info
Even if no messaging platforms are configured, the gateway stays running for cron. A file lock prevents duplicate execution if multiple processes overlap.
:::
## Delivery Options
## Delivery options
When scheduling jobs, you specify where the output goes:
@ -70,48 +185,34 @@ When scheduling jobs, you specify where the output goes:
|--------|-------------|---------|
| `"origin"` | Back to where the job was created | Default on messaging platforms |
| `"local"` | Save to local files only (`~/.hermes/cron/output/`) | Default on CLI |
| `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` env var |
| `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` env var |
| `"telegram:123456"` | Specific Telegram chat by ID | For directing output to a specific chat |
| `"discord:987654"` | Specific Discord channel by ID | For directing output to a specific channel |
| `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` |
| `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` |
| `"telegram:123456"` | Specific Telegram chat by ID | Direct delivery |
| `"discord:987654"` | Specific Discord channel by ID | Direct delivery |
**How `"origin"` works:** When a job is created from a messaging platform, Hermes records the source platform and chat ID. When the job runs and deliver is `"origin"`, the output is sent back to that exact platform and chat. If origin info isn't available (e.g., job created from CLI), delivery falls back to local.
The agent's final response is automatically delivered. You do not need to call `send_message` in the cron prompt.
**How platform names work:** When you specify a bare platform name like `"telegram"`, Hermes first checks if the job's origin matches that platform and uses the origin chat ID. Otherwise, it falls back to the platform's home channel configured via environment variable (e.g., `TELEGRAM_HOME_CHANNEL`).
## Schedule formats
The agent's final response is automatically delivered — you do **not** need to include `send_message` in the cron prompt.
### Relative delays (one-shot)
The agent knows your connected platforms and home channels — it'll choose sensible defaults.
## Schedule Formats
### Relative Delays (One-Shot)
Run once after a delay:
```
```text
30m → Run once in 30 minutes
2h → Run once in 2 hours
1d → Run once in 1 day
```
Supported units: `m`/`min`/`minutes`, `h`/`hr`/`hours`, `d`/`day`/`days`.
### Intervals (recurring)
### Intervals (Recurring)
Run repeatedly at fixed intervals:
```
```text
every 30m → Every 30 minutes
every 2h → Every 2 hours
every 1d → Every day
```
### Cron Expressions
### Cron expressions
Standard 5-field cron syntax for precise scheduling:
```
```text
0 9 * * * → Daily at 9:00 AM
0 9 * * 1-5 → Weekdays at 9:00 AM
0 */6 * * * → Every 6 hours
@ -119,155 +220,63 @@ Standard 5-field cron syntax for precise scheduling:
0 0 * * 0 → Every Sunday at midnight
```
#### Cron Expression Cheat Sheet
### ISO timestamps
```
┌───── minute (0-59)
│ ┌───── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌───── month (1-12)
│ │ │ │ ┌───── day of week (0-7, 0 and 7 = Sunday)
│ │ │ │ │
* * * * *
Special characters:
* Any value
, List separator (1,3,5)
- Range (1-5)
/ Step values (*/15 = every 15)
```
:::note
Cron expressions require the `croniter` Python package. Install with `pip install croniter` if not already available.
:::
### ISO Timestamps
Run once at a specific date/time:
```
```text
2026-03-15T09:00:00 → One-time at March 15, 2026 9:00 AM
```
## Repeat Behavior
## Repeat behavior
The `repeat` parameter controls how many times a job runs:
| Schedule Type | Default Repeat | Behavior |
| Schedule type | Default repeat | Behavior |
|--------------|----------------|----------|
| One-shot (`30m`, timestamp) | 1 (run once) | Runs once, then auto-deleted |
| Interval (`every 2h`) | Forever (`null`) | Runs indefinitely until removed |
| Cron expression | Forever (`null`) | Runs indefinitely until removed |
| One-shot (`30m`, timestamp) | 1 | Runs once |
| Interval (`every 2h`) | forever | Runs until removed |
| Cron expression | forever | Runs until removed |
You can override the default:
You can override it:
```python
schedule_cronjob(
cronjob(
action="create",
prompt="...",
schedule="every 2h",
repeat=5 # Run exactly 5 times, then auto-delete
repeat=5,
)
```
When a job hits its repeat limit, it is automatically removed from the job list.
## Managing jobs programmatically
## Real-World Examples
### Daily Standup Report
```
Schedule a daily standup report: Every weekday at 9am, check the GitHub
repository at github.com/myorg/myproject for:
1. Pull requests opened/merged in the last 24 hours
2. Issues created or closed
3. Any CI/CD failures on the main branch
Format as a brief standup-style summary. Deliver to telegram.
```
The agent creates:
```python
schedule_cronjob(
prompt="Check github.com/myorg/myproject for PRs, issues, and CI status from the last 24 hours. Format as a standup report.",
schedule="0 9 * * 1-5",
name="Daily Standup Report",
deliver="telegram"
)
```
### Weekly Backup Verification
```
Every Sunday at 2am, verify that backups exist in /data/backups/ for
each day of the past week. Check file sizes are > 1MB. Report any
gaps or suspiciously small files.
```
### Monitoring Alerts
```
Every 15 minutes, curl https://api.myservice.com/health and verify
it returns HTTP 200 with {"status": "ok"}. If it fails, include the
error details and response code. Deliver to telegram:123456789.
```
The agent-facing API is one tool:
```python
schedule_cronjob(
prompt="Run 'curl -s -o /dev/null -w \"%{http_code}\" https://api.myservice.com/health' and verify it returns 200. Also fetch the full response with 'curl -s https://api.myservice.com/health' and check for {\"status\": \"ok\"}. Report the result.",
schedule="every 15m",
name="API Health Check",
deliver="telegram:123456789"
)
cronjob(action="create", ...)
cronjob(action="list")
cronjob(action="update", job_id="...")
cronjob(action="pause", job_id="...")
cronjob(action="resume", job_id="...")
cronjob(action="run", job_id="...")
cronjob(action="remove", job_id="...")
```
### Periodic Disk Usage Check
For `update`, pass `skills=[]` to remove all attached skills.
```python
schedule_cronjob(
prompt="Check disk usage with 'df -h' and report any partitions above 80% usage. Also check Docker disk usage with 'docker system df' if Docker is installed.",
schedule="0 8 * * *",
name="Disk Usage Report",
deliver="origin"
)
```
## Job storage
## Managing Jobs
Jobs are stored in `~/.hermes/cron/jobs.json`. Output from job runs is saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`.
```bash
# CLI commands
hermes cron list # View all scheduled jobs
hermes cron status # Check if the scheduler is running
The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind.
# Slash commands (inside chat)
/cron list
/cron remove <job_id>
```
The agent can also manage jobs conversationally:
- `list_cronjobs` — Shows all jobs with IDs, schedules, repeat status, and next run times
- `remove_cronjob` — Removes a job by ID (use `list_cronjobs` to find the ID)
## Job Storage
Jobs are stored as JSON in `~/.hermes/cron/jobs.json`. Output from job runs is saved to `~/.hermes/cron/output/{job_id}/{timestamp}.md`.
The storage uses atomic file writes (temp file + rename) to prevent corruption from concurrent access.
## Self-Contained Prompts
## Self-contained prompts still matter
:::warning Important
Cron job prompts run in a **completely fresh agent session** with zero memory of any prior conversation. The prompt must contain **everything** the agent needs:
- Full context and background
- Specific file paths, URLs, server addresses
- Clear instructions and success criteria
- Any credentials or configuration details
Cron jobs run in a completely fresh agent session. The prompt must contain everything the agent needs that is not already provided by attached skills.
:::
**BAD:** `"Check on that server issue"`
**GOOD:** `"SSH into server 192.168.1.100 as user 'deploy', check if nginx is running with 'systemctl status nginx', and verify https://example.com returns HTTP 200."`
:::
## Security
:::warning
Scheduled task prompts are scanned for instruction-override patterns (prompt injection). Jobs matching threat patterns like credential exfiltration, SSH backdoor attempts, or prompt injection are blocked at creation time. Content with invisible Unicode characters (zero-width spaces, directional overrides) is also rejected.
:::
Scheduled task prompts are scanned for prompt-injection and credential-exfiltration patterns at creation and update time. Prompts containing invisible Unicode tricks, SSH backdoor attempts, or obvious secret-exfiltration payloads are blocked.

View file

@ -22,7 +22,7 @@ High-level categories:
| **Media** | `vision_analyze`, `image_generate`, `text_to_speech` | Multimodal analysis and generation. |
| **Agent orchestration** | `todo`, `clarify`, `execute_code`, `delegate_task` | Planning, clarification, code execution, and subagent delegation. |
| **Memory & recall** | `memory`, `session_search`, `honcho_*` | Persistent memory, session search, and Honcho cross-session context. |
| **Automation & delivery** | `schedule_cronjob`, `send_message` | Scheduled tasks and outbound messaging delivery. |
| **Automation & delivery** | `cronjob`, `send_message` | Scheduled tasks with create/list/update/pause/resume/run/remove actions, plus outbound messaging delivery. |
| **Integrations** | `ha_*`, MCP server tools, `rl_*` | Home Assistant, MCP, RL training, and other integrations. |
For the authoritative code-derived registry, see [Built-in Tools Reference](/docs/reference/tools-reference) and [Toolsets Reference](/docs/reference/toolsets-reference).