How the Cron Scheduler Integrates with the Messaging Gateway in Hermes Agent

The Hermes Agent cron scheduler runs as a background thread inside the gateway process, executing due jobs every 60 seconds and routing their output to chat platforms via the unified send_message tool.

The NousResearch/hermes-agent repository combines a Python-based cron scheduler with a multi-platform messaging gateway to enable fully automated, agent-driven workflows. When a scheduled job completes, the system seamlessly hands the result to the messaging gateway for delivery to Telegram, Discord, Slack, or other configured channels without requiring external cron daemons or manual intervention.

The Gateway's Background Cron Ticker

When you launch the gateway with hermes gateway start, the main process spawns a daemon thread that keeps the cron scheduler alive. In gateway/run.py, the _start_cron_ticker function creates this persistent loop:


# gateway/run.py – lines 2855-2866

def _start_cron_ticker(stop_event: threading.Event, adapters=None, interval: int = 60):
    """
    Background thread that ticks the cron scheduler at a regular interval.
    Runs inside the gateway process so cronjobs fire automatically without
    needing a separate `hermes cron daemon` or system cron entry.
    """
    from cron.scheduler import tick as cron_tick
    ...
    while not stop_event.is_set():
        cron_tick(verbose=False)          # invokes the scheduler

        stop_event.wait(timeout=interval)

This thread calls cron.scheduler.tick() every 60 seconds, ensuring jobs fire on time even when no user is actively chatting with the agent. The implementation uses a threading.Event for clean shutdown and runs entirely in-process, eliminating the need for separate cron services or system-level cron entries.

Job Execution Pipeline

The core scheduling logic resides in cron/scheduler.py. The tick() function checks for due jobs, executes them, and triggers delivery.

Tick Loop and Due Job Detection

Each tick acquires a file lock to prevent overlapping executions between the gateway ticker and any standalone hermes cron tick commands:


# cron/scheduler.py

def tick(verbose: bool = True) -> int:
    """
    Check and run all due jobs.
    Uses a file lock so only one tick runs at a time.
    """
    # ... acquire lock ...

    due_jobs = get_due_jobs()              # from cron/jobs.py

    for job in due_jobs:
        success, output, final_response, error = run_job(job)
        # ... store output, mark run, deliver result ...

The get_due_jobs() helper (from cron/jobs.py) queries the persistent jobs.json store to find jobs whose next run time has passed.

Creating the AIAgent Instance

For each due job, run_job() constructs a temporary AIAgent instance that executes the user's prompt using the same inference stack as interactive chats:


# cron/scheduler.py – run_job()

agent = AIAgent(
    model=model,
    api_key=runtime.get("api_key"),
    ...,
    session_id=f"cron_{job_id}_{_hermes_now().strftime('%Y%m%d_%H%M%S')}"
)
result = agent.run_conversation(prompt)
final_response = result.get("final_response", "")

The agent receives a unique session_id formatted as cron_{job_id}_{timestamp}, isolating cron runs from user conversations while maintaining full access to tools and configuration. This design allows scheduled jobs to perform web searches, file operations, or API calls exactly like human-driven sessions.

Persisting Output to Disk

After execution, the scheduler saves a markdown transcript to ~/.hermes/cron/output/{job_id}/{timestamp}.md via save_job_output():

output_file = save_job_output(job["id"], output)

Simultaneously, mark_job_run() (from cron/jobs.py) updates the job metadata with the next scheduled time, success status, and any error text.

Delivering Results Through the Messaging Gateway

The final integration point is _deliver_result(), which bridges the scheduler and the messaging gateway. This function determines the destination platform and chat, then hands the content to the cross-platform send-message infrastructure.

Platform and Channel Resolution

The scheduler supports three delivery modes configured per job:

  • deliver="local": Write output to disk only; no message sent
  • deliver="origin": Send back to the chat where the job was created
  • deliver="platform" or deliver="platform:chat_id": Send to a specific platform (Telegram, Discord, Slack) or explicit channel

If the target platform is specified without a chat ID, the system falls back to the home channel defined in environment variables (e.g., TELEGRAM_HOME_CHANNEL), emitting a warning when using defaults.

The Unified Send-Message Tool

The actual API transmission happens through tools/send_message_tool.py. The _deliver_result() function invokes _send_to_platform(), which abstracts platform-specific protocols:


# cron/scheduler.py – _deliver_result()

def _deliver_result(job: dict, content: str) -> None:
    deliver = job.get("deliver", "local")
    ...
    from tools.send_message_tool import _send_to_platform
    result = asyncio.run(_send_to_platform(platform, pconfig, chat_id, content))

The send-message tool handles authentication, rate limiting, and API formatting for each backend:


# tools/send_message_tool.py

async def _send_to_platform(platform, pconfig, chat_id, message):
    if platform == Platform.TELEGRAM:
        return await _send_telegram(pconfig.token, chat_id, message)
    elif platform == Platform.DISCORD:
        return await _send_discord(pconfig.token, chat_id, message)
    elif platform == Platform.SLACK:
        return await _send_slack(pconfig.token, chat_id, message)
    ...

Because the scheduler runs inside the gateway process, asyncio.run() executes safely even from the background ticker thread. If an event loop is already running, the tool automatically falls back to a temporary executor thread (lines 119-128 of send_message_tool.py).

Complete Workflow Example

Consider scheduling a daily morning briefing:

hermes cron add "0 8 * * *" \
    "Search the web for AI news from the last 24h and summarise. Deliver to telegram." \
    --name "daily-briefing" --deliver telegram

The internal execution flow follows this path:

  1. 08:00 UTC: gateway/run.py → _start_cron_ticker invokes cron.scheduler.tick()
  2. Job execution: cron/scheduler.py → run_job() creates an AIAgent, fetches news, and generates final_response
  3. Persistence: Output written to ~/.hermes/cron/output/daily-briefing/20240115_080000.md
  4. Delivery: _deliver_result() resolves the Telegram home channel from gateway/config.py and calls _send_to_platform()
  5. Receipt: The message appears in the Telegram channel; gateway/mirror.py reflects it back into the session history for UI consistency

Summary

  • The cron scheduler runs as a daemon thread inside the Hermes Agent gateway, ticking every 60 seconds via _start_cron_ticker in gateway/run.py.
  • Job execution creates ephemeral AIAgent instances with isolated session IDs, ensuring cron tasks have full tool access without polluting user conversation history.
  • Results are persisted to disk in ~/.hermes/cron/output/ and then routed through the messaging gateway using the unified send_message tool.
  • The send-message tool (tools/send_message_tool.py) abstracts platform APIs (Telegram, Discord, Slack), allowing the scheduler to deliver messages to origin chats, home channels, or explicit targets without platform-specific code.
  • File locking in cron/scheduler.py prevents race conditions between the gateway ticker and standalone CLI invocations like hermes cron tick.

Frequently Asked Questions

How does the cron scheduler prevent duplicate job executions when both the gateway and CLI are running?

The tick() function in cron/scheduler.py acquires a file-level lock before checking for due jobs. This locking mechanism ensures that only one process—whether the gateway's background thread or a standalone hermes cron tick command—can execute the scheduler at any given time, preventing double-runs and race conditions.

Can scheduled jobs send messages to different platforms than where they were created?

Yes. The deliver parameter in _deliver_result() accepts platform-specific targets such as telegram, discord, or slack. You can also specify explicit channels using the platform:chat_id syntax. If no chat ID is provided, the scheduler falls back to the configured home channel (e.g., TELEGRAM_HOME_CHANNEL from gateway/config.py).

What happens if the messaging gateway is offline when a cron job finishes?

The cron scheduler writes output to disk immediately via save_job_output() before attempting delivery. If the gateway is unreachable or the platform API fails, the output remains stored in ~/.hermes/cron/output/. The next successful tick will not re-send failed deliveries automatically; you must check the output files or logs for failure notifications.

How does the scheduler handle asynchronous message sending from a background thread?

The _deliver_result() function uses asyncio.run() to execute the coroutine _send_to_platform() synchronously. Because the cron ticker runs in a daemon thread within the gateway process, this pattern works safely. The send-message tool also includes fallback logic (lines 119-128 in tools/send_message_tool.py) that detects existing event loops and delegates to a temporary thread if necessary, preventing RuntimeError exceptions.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →