Table of contents
- Current Milestone — 0.9.x
- Theme by theme
- Multi-agent wake semantics
- Dispatcher robustness + restart recovery
- Web UI performance + UX
- Capabilities preamble for Claude Code
- Agent-to-agent notification + spam folder discovery
- Codex CLI integration
- Multi-host coordination — the heart of the cycle
- Where things stand
- Tests
- Open follow-ups (carry into next milestone)
Current Milestone — 0.9.x
The 0.9.x cycle (2026-05-14 → 2026-05-15) was a focused push that turned AgenticMail from "works for the happy path" into "operationally robust under multi-agent broadcast bursts, restart cycles, real web-UI usage, AND co-installed multi-host coordination." 24 releases over about 48 hours, almost all driven by user reports against running production traffic.
This page is the human-readable ledger; the CHANGELOG has the canonical entry per version.
Theme by theme
Multi-agent wake semantics
The first half of 0.9.x rebuilt how the dispatcher decides whose turn it is to take a Claude turn when a mail lands on a thread with multiple recipients.
| Version | Change |
|---|---|
| 0.9.0 | Wake default changed from "wake every CC'd recipient" to To-only + explicit wake: [...] opt-in. Adds ThreadCache (last K envelopes per thread, on disk) and AgentMemoryStore (per-(agent, thread) markdown the worker writes via save_thread_memory). |
| 0.9.1 | Visibility — dispatcher activity registry, check_activity MCP tool, per-recipient wasOnTo flag on SSE events. |
| 0.9.2 | reply_email rewritten: put original sender on To, others on Cc (was lumping everyone on To, defeating wake gating). |
| 0.9.4 | Per-agent serialization. At most ONE worker runs per agent at a time. Burst of 5 emails for the same agent serializes through a Map<agentId, Promise> chain. Global cap bumped 10 → 50. Plus unhandledRejection + uncaughtException guards in dispatcher-bin.ts that log + continue rather than terminating. |
| 0.9.5 | Dedup queued wakes against UIDs the worker already read mid-turn — a read_email(uid) from inside a running worker now drops any queued wake on that UID. |
Dispatcher robustness + restart recovery
The dispatcher used to crash on bundling errors, lose all dedup state on restart, and silently drop any mail that arrived during downtime.
| Version | Change |
|---|---|
| 0.9.6 | Fixed crash-loop on startup: Dynamic require of "events" is not supported. Root cause: dispatcher.ts imported @agenticmail/core but core was missing from package.json dependencies, so tsup inlined it (along with nodemailer/imapflow/mailparser's CJS code) into the ESM bundle. Adding core to deps fixed it. |
| 0.9.7 | Fixed TypeError: Cannot read properties of undefined (reading 'uid') on lone leading-edge wakes — sentinel queue entry with events: [] would hit newMailPromptForBatch([]) after the debounce window if no follow-up events arrived. Added short-circuit. |
| 0.9.8 | Restart recovery. New dispatcher-state.ts persists { lastSeenUid, seenUids[] } per account at ~/.agenticmail/dispatcher-state.json (debounced 2s, atomic .tmp + rename, capped at 256 UIDs/account). On restart: seenUids restore (IDLE replays stay deduped), and a one-shot catch-up scan via /mail/inbox + /tasks/pending synthesizes SSE events for unprocessed UIDs/tasks. First-run safety: empty cursor seeds at max UID instead of replaying the whole inbox. |
Web UI performance + UX
The web UI was unusably slow and had several broken interactions. Most got fixed in a 24-hour stretch driven by direct user reports.
| Version | Change |
|---|---|
| 0.9.8 | /mail/digest rewritten: ~2.4s → ~150ms. Was fetching the FULL RFC822 source of every UID just to slice the first 240 chars of body; now does one mailbox lock with SEARCH ALL (authoritative total) + truncated source fetch (8 KB max) + Promise.all over mailparser. Pagination's total switched from stale mailboxInfo.exists to the SEARCH-derived count, fixing "Next button stuck disabled on every folder >50 messages." |
| 0.9.9 | SSE multiplex. sse.js was opening one SSE connection PER agent. With 5 agents + the /system/events activity-badge stream = 6 connections, exactly the browser cap. Every other request hung waiting for a slot. Refactor: one shared /system/events SSE for the whole UI; per-agent new-mail events fan out via pushSystemEvent({type:'new_mail', agentId, ...}). 6 → 1 connections. Also: parsed-message LRU (60s TTL, 200 entries) for /mail/messages/:uid, stripped raw attachment binaries from the response, added Cache-Control headers on static assets, and FIXED all 78 TypeScript errors in the api package (root cause: @types/express@^5 with express@^4 runtime). |
| 0.9.10 | Back button from message detail no longer leaves the message view stuck on screen. The 0.9.8 route() rewrite short-circuited on selectedFolder equality, but the folder doesn't change when opening a message — so Back to the folder list early-returned and left message-detail DOM in place. Added a currentView tracker. |
| 0.9.11 | Activity badges paint immediately on page load instead of waiting for the next worker heartbeat (~30s). Added a one-shot GET /dispatcher/activity backfill on subscribeToActivity(). |
| 0.9.12 | Pager Prev button was rendered as a literally empty <button> — data-icon="back" was metadata only, nothing populated innerHTML. Only Next got the icon assigned. Fix: assign icon HTML to both. (Also in 0.9.12: SessionStart hook — see below.) |
| 0.9.13 | Wake allowlist silently excluded everyone when sent as a JSON string. normalizeWakeList had a CSV fallback that turned wake: '["orion"]' (Claude sometimes JSON-stringifies arrays) into ['["orion"]'] — a single-name list with brackets/quotes baked in. Fix: detect [...] shape, JSON.parse, fall back to CSV. Added 7 regression tests. |
| 0.9.14 | Moving an email to Spam/Archive: mail disappeared from inbox but never landed in target. Two bugs: (a) API destructured {from, to} but UI sent {folder, toFolder} → 400 silently → mail never moved; (b) folder cache went stale after first-time folder creation, so Archive tab showed "No Archive folder" even after one had just been created. Fixed both. |
Capabilities preamble for Claude Code
| Version | Change |
|---|---|
| 0.9.12 | Registered the mail-hook on SessionStart in addition to UserPromptSubmit + Stop. SessionStart fires on startup / resume / compact — critically including post-auto-compaction, where session_id stays the same so naive per-session-id dedup would silently swallow the re-inject. Carries a ~250-token capabilities blurb explaining when to reach for AgenticMail (multi-role parallel work, durable async coordination, sub-tasks that need to talk to each other) and the three high-leverage tools (create_account, send_email with wake, call_agent/wait_for_email). |
Agent-to-agent notification + spam folder discovery
The dispatcher fired correctly but users weren't getting visible/audible cues when agents replied to each other.
| Version | Change |
|---|---|
| 0.9.15 | Sound + system event was firing only on the first branch of events.ts's new-mail handler. Three early-return paths all called safeWrite but skipped pushSystemEvent — so agent-to-agent mail wrote to the per-account SSE channel (which fed inbox refresh) but never hit /system/events (which feeds the activity badge + browser notification + sound). Refactored to a single broadcastNew(event) helper that always does both. User feedback was sharp: "FIX THIS AND THEN WE CAN COME BACK TO THE CODEX." |
| 0.9.16 | Spam tab was empty even when Stalwart had spam-classified mail. Root cause: the spam route hard-coded 'Spam' as the folder name, but Stalwart's default folder is 'Junk Mail'. The archive/batch routes already discovered the folder dynamically — pulled the same code into the spam route. |
Codex CLI integration
The second host integration. ~90% architectural reuse from the Claude Code package, with one-time transforms for TOML vs JSON config, persona format, and SDK swap.
| Version | Change |
|---|---|
| 0.9.17 | Ship @agenticmail/codex@0.1.0. Installer writes [mcp_servers.agenticmail] into ~/.codex/config.toml, generates one .toml per AgenticMail account in ~/.codex/agents/, registers mail-hook entries in ~/.codex/hooks.json for SessionStart/UserPromptSubmit/Stop, and starts an agenticmail-codex-dispatcher PM2 daemon that uses @openai/codex-sdk's startThread().run() where the Claude Code dispatcher uses claude-agent-sdk's query(). Confirmed: Codex's ClaudeHooksEngine Rust crate eats the existing hook JSON unchanged — same hook binary works for both hosts. |
| 0.9.18 | Codex install was 400'ing because it passed role: 'bridge' but the API hadn't accepted the role yet. Shipped a workaround that used role: 'assistant'. Acknowledged at the time as temporary. |
Multi-host coordination — the heart of the cycle
The 0.9.18 workaround set up a deeper problem: with both Claude Code and Codex dispatchers running on the same machine, each watched every teammate and BOTH fired workers on every reply. Duplicate replies, duplicate token spend. Two months of design discussion compressed into three releases.
| Version | Change |
|---|---|
| 0.9.19 | role='bridge' canonicalization + cross-host filter. Added 'bridge' to AGENT_ROLES in @agenticmail/core so the API accepts it on POST /accounts and PATCH /accounts/:id/role. Both host installers now provision their bridge with role='bridge' directly, and migrate pre-existing role='assistant' bridges from 0.9.18 in-place via setAccountRole. Dispatcher's shouldWatch gained two new filter layers: skip anything tagged role='bridge' (any host), and skip anything with metadata.bridge === true (legacy marker). Codex hook UX improved: the install now prints the three hook commands so the user can paste them into Codex's /hooks approval prompt on first run. |
| 0.9.20 | Per-account host ownership (metadata.host). Every account now carries an optional metadata.host field naming the host that owns it. MCP create_account auto-stamps the value from the AGENTICMAIL_MCP_HOST env var (set by each installer in its MCP server's env block), so a Claude Code session asking the MCP server to create_account({name:'Felix'}) produces an account stamped host='claudecode' automatically. Dispatcher's shouldWatch reads the field: own-host = watch, other-host = skip, unset = watch (legacy fall-through). New REST endpoints: PATCH /accounts/:id/role and PATCH /accounts/:id/host (both master-key scoped). New agenticmail-<host> claim <name> [--all] [--unclaim] CLI to retro-tag legacy accounts. Web UI gained host badges in the agent list (purple Claude, green Codex, gray Unclaimed). |
| 0.9.21 | Wrapper bins for transitive optional dependencies. npm install -g @agenticmail/cli@latest left agenticmail-claudecode and agenticmail-codex off PATH despite the code being installed — npm only symlinks bins of the directly-installed package, transitive optionalDependency bins stay buried in node_modules. Fix: @agenticmail/cli ships its own agenticmail-claudecode + agenticmail-codex bins (wrappers compiled from bin-claudecode.ts / bin-codex.ts). Each wrapper walks up the filesystem to locate the host package's package.json (avoids the ESM-only exports gate — createRequire().resolve() can't satisfy import-only entries), reads pkg.bin[name], and re-execs the real binary with stdio inherited and signals forwarded. One install, three bins on PATH. |
| 0.9.22 | Bridges no longer leak into other hosts' subagent lists. A fresh agenticmail-codex install on a machine with Claude Code already set up wrote a subagent file for agenticmail-claudecode — treating the OTHER host's bridge as a teammate. Two compounding root causes: (a) selectExposableAgents only filtered on role === 'bridge', but the Claude bridge in the test rig still carried the legacy role='assistant' because the role migration only runs on install re-run; (b) the installer never stamped metadata.host on its own bridge — MCP's auto-stamp only fires on accounts created via MCP, but the bridge is created via the master API directly. Fix in both host packages: selectExposableAgents now also filters on metadata.host and metadata.bridge; install() now calls setAccountHost(bridgeId, bridgeName) after ensureAccount. |
| 0.9.23 | Strict host ownership for subagent rosters + per-host avatars. The 0.9.22 fix still let unclaimed accounts leak through to both hosts (back-compat). Tightened: a teammate appears in a host's subagent roster ONLY if metadata.host === ownHost. Legacy unclaimed accounts must be explicitly claimed via agenticmail-<host> claim --all. Dispatcher's shouldWatch intentionally NOT tightened — it keeps the legacy unclaimed-watch behavior for back-compat with single-host installs. Also: the web UI's bridge avatar was hard-coded to Claude's mark for every bridge — co-installed Claude+Codex looked identical. New HOST_BRANDING registry in packages/api/public/js/avatar.js maps host name to logo URL. Claude bridge gets the official orange Anthropic mark (/branding/claude-color.svg); Codex bridge gets the OpenAI rosette (/branding/openai-mark.svg); unknown hosts fall back to the AgenticMail logo + verified tick. Adding a new host integration = one row in the registry plus drop the SVG into /branding/. |
Where things stand
- Dispatcher: survives restarts, replays missed mail/tasks on reconnect, dedupes IMAP IDLE replays, never crashes on a single bad event. Per-agent serialization prevents burst-driven crashes. Cross-host filtering: only watches accounts the host owns (plus legacy unclaimed for back-compat).
- Web UI: loads in ~200 ms instead of ~3 s, back button works, pagination works on >50-message folders, move-to-spam/archive works, badges paint on connect, refresh doesn't hang, host-aware avatars distinguish each bridge.
- MCP toolbelt: 60+ tools, with the capabilities blurb landing on every fresh Claude Code / Codex session (including post-compact) so the model knows when to reach for AgenticMail.
create_accountauto-stampsmetadata.hostfrom the MCP server's env block. - Multi-host coordination: Claude Code + Codex CLI both shipping; co-installed without dual-wake. Each host's roster is its own — neither inherits the other's teammates. Operator can retro-tag legacy accounts via
agenticmail-<host> claim. - Test coverage: 600+ tests pass across core/api/claudecode/codex/mcp.
Tests
| Package | Test count |
|---|---|
@agenticmail/core |
362 |
@agenticmail/claudecode |
136 |
@agenticmail/codex |
80 |
@agenticmail/api |
19 |
@agenticmail/cli |
61 |
| Total | 658 |
Open follow-ups (carry into next milestone)
- The dispatcher's auto-compact-aware capabilities blurb is shared across Claude Code and Codex via byte-compatible hook ABI, but Grok Build and Hermes Agent will need their own variants when those integrations ship.
- The IMAP receiver pool uses one connection per agent; a flood of message-detail clicks can serialize on the mailbox lock. Add a small connection pool.
- The wake-budget circuit breaker is in-memory (resets on restart). Move it into the persisted
dispatcher-state.jsonso a malicious or runaway thread can't "reset" by crashing the dispatcher. pending_outboundflow has pre-existing TS errors disabled with strict mode at the per-route level — clean up to keep the strict-mode signal honest.- Dispatcher's
shouldWatchstill falls through to "watch" on unclaimed accounts for single-host back-compat. Install-time filter is strict (only-mine), but dispatcher-time isn't yet. Tighten once telemetry shows the env-var auto-stamp is universally present (planned for 0.10). - Cross-host telemetry:
check_activitydoesn't yet distinguish "vesper (claudecode) working" from "orion (codex) working" in one view. The metadata is there (metadata.hoston every account) — just need to surface it.