4.8 KiB
Cron Workflows for Email Archive
Architecture overview
Two independent cron pipelines that share the same email storage:
mail-archive (every 5 min, no_agent=True)
↓
INBOX/UID/email.md ←── contacts-extractor (every 30 min, script-based, LLM)
↓
contacts.vcf + contacts.json
Both use script-based cron (the script= parameter) with the script in
~/.hermes/scripts/. The archive job is no_agent=True (pure shell — no LLM
tokens consumed). The contacts job uses the agent loop because it calls LLM
internally via Python.
Mail archive cron (bulk sync)
# Script: ~/.hermes/scripts/mail-archive.sh
#!/usr/bin/env bash
set -euo pipefail
cd /opt/hermes/email-assistant
exec python3 scripts/mail_archive.py --all --limit 10
Cron creation:
hermes cron create \
--name "mail-archive-every-5min" \
--schedule "every 5m" \
--script mail-archive.sh \
--no-agent \
--deliver local
Key details:
no_agent=True→ pure script mode, zero LLM cost per tickdeliver=local→ output saved, no notification (noisy at 5min intervals)- Script path MUST be relative in
~/.hermes/scripts/— absolute paths rejected --all --limit 10processes all 22 folders with 10 emails per folder per tick
Shell wrapper with per-folder timeouts
When using --all, one slow IMAP folder can stall the entire run. The
shell-embedded approach handles this natively — each folder gets its own
timeout:
FOLDERS=(INBOX "Отправленные" Archive Sent ...)
TIMEOUT=60
LIMIT=10
for folder in "${FOLDERS[@]}"; do
timeout $TIMEOUT python3 scripts/mail_archive.py --folder "$folder" --limit $LIMIT 2>&1 || true
done
This is the ACTUAL approach used in mail-archive.sh. The --all flag in
mail_archive.py iterates folders internally but without per-folder timeouts,
so the shell wrapper is the recommended pattern when you control the cron script.
Contacts extractor cron (LLM-based)
# Script: ~/.hermes/scripts/contacts-cron.sh
#!/usr/bin/env bash
set -euo pipefail
cd /opt/hermes/email-assistant
exec python3 scripts/contacts_extractor.py --limit 15
Cron creation:
hermes cron create \
--name "contacts-extractor-every-30m" \
--schedule "every 30m" \
--script contacts-cron.sh \
--deliver local
Key differences from archive cron:
- NO
--no-agent— the contacts extractor calls LLM (Qwen3:8b via Ollama) internally. Without the agent loop, the script runs but output isn't delivered/visible. --limit 15— Qwen3:8b takes ~20s per email. 15 emails × 20s = ~5 min, well within the 30-min window. Bump to 25-30 if Qwen is on a GPU.deliver=local— results saved to disk, no notification. The agent generates a summary message on each tick.
Transition from bulk to incremental
When state files stop advancing (all emails archived):
- Update archive cron: smaller limit or longer interval
hermes cron update <archive-id> --schedule "every 30m" - Keep contacts cron at
every 30m— it always processes only new emails (UID tracking inlast_scan.json)
Testing cron scripts
Before scheduling, verify the script works by running it once:
timeout 120 bash ~/.hermes/scripts/contacts-cron.sh
Check for:
- Exit code 0 = success; 124 = timeout (reduce
--limit) - Stale output = script isn't finding new files (check
last_scan.jsonUIDs) - Python import errors = missing dependencies (run
pip install -r requirements.txt)
Pitfalls
-
Script path MUST be relative.
--script /absolute/pathis silently rejected. Copy the script to~/.hermes/scripts/and pass just the filename. -
Contacts cron needs the agent loop. Unlike the pure-shell archive cron, contacts cron must NOT have
--no-agent. Without the agent, the LLM calls incontacts_extractor.pystill execute (it's Python), but the job output is never delivered — you'd see "last_status=completed, last_output=" even though contacts.vcf was updated. -
Qwen3:8b speed varies. On CPU-only Ollama it's ~20s/email. On discrete GPU (NVIDIA, AMD ROCm) it's ~2-3s/email. Set
--limitaccordingly:- CPU: 10-15 emails per 5-min cron window
- GPU: 50-100 emails per 5-min window
-
Cron jobs run from the session's last state, not a fresh login. Environment variables (like
PATH) may differ. Always use absolute paths orcdto the project directory in the script. -
deliver=localvsdeliver=origin.localsaves output to the cron DB only (viewable viacronjob action=list).originsends it back to the Hermes session that created the cron. For per-5min archive runs,localavoids spam. For contacts (every 30min), consideroriginif you want a notification.