Autonomous email triage: every 30 minutes it polls a Resend receiving inbox, spawns one headless Claude session per email to decide reply / ask-a-human-on-Telegram / ignore, executes the decision, and flags the email processed so it is never re-fed.
Status: running in production under launchd on an always-on macOS host, on a 30-minute interval.
The interesting part is not the LLM call — it's the envelope around it:
- Exactly-once sends. Every decision is journaled to
state/inflight/before its side effect and only cleared after. A crash replays the same decision next tick (no re-deciding), and Resend idempotency keys (ea:reply:<emailId>) make replayed sends exactly-once. Journals older than 12 h (half Resend's 24 h idempotency window) alert you instead of blind-replaying. - Hard caps. ≤5 replies per run, ≤3 replies per sender per day, ≤5 emails decided per run. A surge of >25 new emails at once (mail loop, spam wave) skips the whole batch and alerts you.
- Mail-loop firewall. Self-mail,
Auto-Submitted,Precedence: bulk/list/junk, and no-reply/mailer-daemon senders are dropped before any LLM call. - No recipient choice. The decision schema has no recipient field; the only
send path hard-locks the recipient to the original sender's
From. InboundReply-Tois deliberately ignored (spoofable redirection surface). - Sender authentication. Autonomous replies go only to senders passing domain-aligned DKIM/DMARC; anything else is downgraded to a clarification. Owner answers by email require the same alignment for the owner's domain.
- Prompt-injection posture. The decision session runs with zero tools, no MCP,
a minimal env (it never sees the Resend or Telegram credentials), and the email
body is fenced as untrusted data with a per-call random nonce.
AGENT.md— the decision contract — instructs the model to treat in-mail instructions as data. - Clarification lifecycle. Unanswered questions are reminded once at 4 h and expire at 48 h with no action taken (you're told).
- Liveness. Daily Telegram heartbeat; an alert after 3 consecutive failed runs, re-alerted roughly every 6 h during an outage.
launchd (every 30 min) → bin/tick.sh (lock, log, prune) → src/run.js
1. replay crashed inflight actions (idempotent — Resend idempotencyKey)
2. read Telegram answers → resume pending clarifications
3. remind (4h) / expire (48h) unanswered clarifications
4. poll Resend Received-Emails API → new mail → skip-classifier → surge guard
5. per email: claude -p (zero tools, pure JSON decision) → execute → mark processed
6. daily Telegram heartbeat; alert after 3 consecutive failed runs
The per-email decision is a hermetic claude -p call: AGENT.md (policy) + the
fenced email + a strict JSON output contract in, one JSON object out
(action, reason, stakes, reply_text / clarify_question). Invalid output
gets one corrective retry, then falls back to asking you.
State lives in state/ on the deployment host only — never sync it between
machines: processed.jsonl (completed flag), pending/ (awaiting your answer),
inflight/ (crash journal), tg-seen.json, rate.json, meta.json.
git clone https://github.com/BaseLayerAI/email-triage-agent
cd email-triage-agent
npm install
cp .env.example .env # fill in every value
npm test # unit tests, no network
node src/run.js --dry-run # full tick, no sends, no state writesThe first real run baselines all pre-existing inbound mail (processes nothing) so the agent never replies to stale threads. Normal processing starts on the second tick.
Inbound uses the Resend account's managed receiving address
(anything@<subdomain>.resend.app lands in the account's Receiving inbox — no
DNS needed). Outgoing mail sets Reply-To to that address, so replies reach the
agent immediately. Optionally, route a nicer public address (e.g.
assistant@yourdomain.com) to it via your mail provider's forwarding/routing
rules, and add it to EA_SELF_ADDRESSES.
| Variable | Required | Purpose |
|---|---|---|
RESEND_API_KEY |
yes | Resend receiving poll + outbound sends |
EA_FROM_ADDRESS |
yes | Outbound From (Name <addr>); domain must be Resend-verified |
EA_OWNER_EMAIL |
yes | Only sender allowed to answer clarifications by email |
TELEGRAM_BOT_TOKEN |
yes* | Bot for clarifications/alerts (*falls back to the Claude Code telegram plugin's env file) |
TELEGRAM_CHAT_ID |
yes | Your numeric Telegram user id |
ANTHROPIC_API_KEY |
no | For headless claude -p under launchd; omit if claude login was run on the host |
EA_REPLY_TO_ADDRESS |
no | Where replies land; default = bare EA_FROM_ADDRESS |
EA_SELF_ADDRESSES |
no | Extra owned addresses for the loop guard (@domain = wildcard) |
EA_SIGNATURE |
no | Override the appended reply signature |
EA_MODEL |
no | Decision model (default sonnet) |
EA_STATE_DIR, EA_SKIP_TELEGRAM_READ |
dev | Test/dev overrides |
Target is any always-on macOS machine reachable over ssh (e.g. via Tailscale):
bash scripts/deploy.sh <ssh-alias-or-user@host>The deploy runs a remote preflight (node ≥20, claude CLI + headless auth smoke
test, disk, clock skew, pmset sleep settings) and refuses to proceed on
failure. It copies .env once (never overwrites), rsyncs the code (excluding
state/, logs/, .env), and installs launchd/com.baselayer.email-triage.plist.
launchd plists cannot expand $HOME, so the plist ships with a __HOME__
placeholder that deploy.sh substitutes at install time (installing by hand:
sed "s|__HOME__|$HOME|g" first).
Telegram note: with a shared bot token, the agent reads updates non-destructively (never confirms offsets) so it can't steal messages from another consumer — but a dedicated bot (two minutes with @BotFather) eliminates the contention class entirely and is recommended.
Ops details — credentials, rotation, failure signatures, recovery — are in docs/RUNBOOK.md.