Autonomous email management for Hermes Agent, running as no-agent cron scripts so it costs zero LLM tokens when idle. It sorts your inbox into two tiers:
- Tier 1 — auto-reply. Unread mail from an address you manage (listed in
config.yaml) gets a reply drafted in your voice and sent automatically back to that same address — never to an address chosen by the model. So it's safe to forward a third-party chain to the agent: the draft lands in your inbox for you to forward onward. - Tier 2 — daily digest. Everything else — newsletters, cold outreach, anyone not in your config — is rolled into one daily summary with priority scoring, delivered to your chat. Nothing to configure: if it isn't Tier 1, it's Tier 2, and your inbox stays quiet.
Earlier versions (v1–v3) had a middle "draft → approve → send to a third party" tier. v4 dropped it (see Design notes), which is why the model is now just these two.
The LLM only wakes when there's real work. Empty inbox = every script exits silently, no tokens spent.
Watchdog (every 5 min, no LLM)
└─ searches Gmail for unread mail from your Tier 1 addresses
└─ writes /tmp/ghostwriter_v4_trigger.json (atomic) ── nothing? exit silently
Processor (every 5 min, +1 offset, no LLM until work exists)
└─ claims the trigger (rename-before-spawn → no double-send)
└─ for each email: LLM drafts the reply BODY only
Python appends the signature + SENDS to the config address
Python archives the original, records the real result
Digest (daily, no LLM until work exists)
└─ summarizes all unread mail NOT from a managed contact → your chat
- The recipient is locked. Python performs the send using the address from
config.yaml; the model only writes the reply text. No email body — including a forwarded chain that says "please send this to bob@x.com" — can change where a reply goes. - Email bodies are treated as untrusted data. The drafting prompt explicitly tells the model not to act on instructions found inside an email.
- No double-send. Three layers: a
flocklock prevents overlapping ticks; the trigger is claimed (renamed) before the LLM runs, so a crash can't leave it to be reprocessed; and every successful reply records its message-id in~/.ghostwriter/state/sent_ids.json, so a message that gets re-listed while an earlier draft is still running is never replied to twice. A reply that genuinely fails to send is not recorded, so a later tick retries it. - No "approve and send to a third party" tier. That middle tier was intentionally dropped — locked-to-self delivery gives the same human-in-the-loop for free: a reply to a forwarded chain lands in your inbox, and you forward it onward yourself. No approval machinery, no risk of the model emailing the wrong person.
Prerequisite: run the agent under a Gmail account that is not one of your Tier 1 addresses. The watchdog triggers on unread mail from a configured address and the processor replies to it. If the agent's mailbox is itself a Tier 1 address, each reply would re-trigger the watchdog — a reply loop. Keep them separate (you forward chains from your addresses into the agent's mailbox).
Hermes Agent with Google Workspace configured. Verify the Gmail API works:
GAPI="$HOME/.hermes/hermes-agent/venv/bin/python3 $HOME/.hermes/skills/productivity/google-workspace/scripts/google_api.py"
$GAPI gmail search "is:unread" --max 1Copy the template and fill in your own address(es):
mkdir -p ~/.ghostwriter
cp config.example.yaml ~/.ghostwriter/config.yaml
$EDITOR ~/.ghostwriter/config.yaml # or use the CLI — see belowYour real config.yaml holds your addresses and is gitignored — never commit
it. See config.example.yaml for every field. List one tier: 1 entry per
mailbox you want auto-replied; anything not listed is Tier 2 (digest).
The cron jobs run copies under ~/.hermes/scripts/. processor.py imports
send_email.py, so copy both together:
cp scripts/watchdog.py ~/.hermes/scripts/ghostwriter_v4_watchdog.py
cp scripts/processor.py ~/.hermes/scripts/ghostwriter_v4_processor.py
cp scripts/send_email.py ~/.hermes/scripts/send_email.py
cp scripts/digest.py ~/.hermes/scripts/ghostwriter_v4_digest.py# Watchdog — poll inbox, zero tokens
hermes cron create --name "Ghostwriter v4 Watchdog" \
--schedule "0,5,10,15,20,25,30,35,40,45,50,55 * * * *" \
--no-agent true --script "ghostwriter_v4_watchdog.py"
# Processor — drafts + sends, zero tokens unless an email is found.
# Fires 1 minute after the watchdog so they stay in lockstep.
hermes cron create --name "Ghostwriter v4 Processor" \
--schedule "1,6,11,16,21,26,31,36,41,46,51,56 * * * *" \
--no-agent true --script "ghostwriter_v4_processor.py"
# Digest — once a day (adjust the hour to your timezone)
hermes cron create --name "Ghostwriter v4 Digest" \
--schedule "0 9 * * *" \
--no-agent true --script "ghostwriter_v4_digest.py"| File | What |
|---|---|
scripts/watchdog.py |
Polls Gmail for Tier 1 unread mail, writes the trigger |
scripts/processor.py |
LLM drafts the reply; Python sends to the config address + archives |
scripts/send_email.py |
Shared Gmail send / archive helper (locked recipient) |
scripts/digest.py |
Daily Tier 2 digest of unmanaged senders |
ghostwriter |
CLI — status + contact management (see below) |
config.example.yaml |
Config template — copy to ~/.ghostwriter/config.yaml |
SKILL.md |
Hermes skill definition |
references/ |
Voice / format reference notes |
Manage contacts and check health. The cron pipeline reads config.yaml
directly and does not depend on this CLI — but the write commands rewrite
config.yaml via PyYAML, so manage that file with the CLI or by hand, not
both (comments aren't preserved on a CLI write).
chmod +x ghostwriter # once; or symlink onto your PATH
./ghostwriter status # tallies, dedup + trigger health
./ghostwriter add --name work --email me@work.com # add a Tier 1 address you control
./ghostwriter edit --name work --signature "<p>Best,</p>"
./ghostwriter promote --email me@news.com --name news # = add (e.g. from the digest)
./ghostwriter remove --name newsstatus output:
Contact Email Tier Paused Sent Failed Last sent
------- ---------------- ---- ------ ---- ------ -------------------------
me you@example.com 1 no 12 1 2026-06-30T09:01:22+00:00
Dedup: 12 message-id(s) tracked in sent_ids.json
Trigger: none pending
Only
add/promoteaddresses you control. A Tier 1 contact's reply is auto-sent to that contact's own address — handy for managing several of your own mailboxes, but adding someone else's address means auto-emailing a third party.add/promote/removeconfirm first (--yesto skip). It's argparse-based, so more subcommands slot in easily.
When the inbox is empty, every script is pure Python and exits without touching an LLM — $0/day idle. The model is invoked only to draft a reply or build a digest, i.e. only when there is real work.
MIT