Agent Email — for agents (part 2)
Part 2 of the Agent Email page: the developer-facing detail behind the feature — the sidebar registration, the captured-inbox columns, the arrival notice, the relay that keeps Firebase admin keys out of the app, the sender and screen-hold rules, the receiving lifecycle and the outbound routes.
What it is
This is part 2 of the Agent Email page. That page covers what the feature is, where to find it and how it behaves for the person using it. This page is the developer-facing half: the routes, the files and the settings that make it work.
Where to find it
Nowhere in the app. Everything here is a route on the local control server, a setting, or a file in the repository, for whoever is working on the feature.
How it behaves
Nothing on this page changes what you see in the app — it is reference for the code behind it. The panel registration, the capture columns, the arrival notice, the relay that holds the data, the sender and screen-hold rules, the receiving lifecycle and the outbound routes all follow.
For agents
For developers / agents
- Sidebar built-in: registered in the integration registry
(
src/shared/integrations/agent-email.ts) as anamc-builtin,tier: 'native',parentGroupId: 'agent-tools', virtual project__agent_email__. SPAWNABLE — the panel hosts a dual-tab sidebar (Emails | Sessions) following the KMS pattern: the Sessions tab usesuseProjectSessionHost+SessionHostSidebar; the Emails tab lists received emails via theagent-email:list-emailsIPC channel. Clicking an email navigates to its session. The panel yields to session chat when a session is displayed (shouldYieldToSessionChat). - Captured inbox content: each inbound email's sender, subject, and a de-quoted preview
(
buildEmailPreviewinsrc/main/services/agentmail-parsing.ts) are stamped intoemail_inbound_tracking(nullablefrom_address/subject/previewcolumns, ledger migration20260812193000) at the create AND continue seams viatrackInboundMessage(…, meta);listAgentEmailsreturns them (+created_at), so each Emails row shows sender · subject · preview · received-date, with a session-name fallback for legacy/NULL rows.I16-inbound-capture-columnsin.claude/memory/contracts/agent-email-contract.md. - Main-inbox arrival notice: when an email starts (after launch) or continues a session,
maybeNotifyEmailArrival(src/main/services/email/email-inbound-session-create.ts) raisesraiseEmailArrivalAlert(src/main/services/email/email-inbound-alerts.ts) — a quietraiseAgentAlertinbox row (DB insert +ALERT_CREATEDonly, so NO OS notification/sound; survives Focus Mode as the durable record). Per-message dedupKeyemail-arrived:<messageId>(one row per email, updates-in-place),sourceSessionIdfor the click-through (I11 provenance link, no new action kind), and DIRECTIVE-FREE copy so it needs no action button (I20). Gated by the per-typeemailArrivalNotificationsEnabledsetting (default on, toggled in the panel) at the call site — soemail-inbound-alerts.tsstays a pure leaf. The bug-intakespawnBugReportSessionpath never calls it. The card carries the wholecleanEmailBodyoutput (de-quoted, 16k-capped there) via thebodyparam, passed throughflowHardWrappedEmail(src/main/services/email/email-body-flow.ts, spectests/unit/services/email-body-flow.test.ts) so the sender's client wraps become paragraphs — NOTbuildEmailPreview, whose 140-char cut and whitespace-collapsing turned the card into a fragment;buildEmailPreviewbelongs on list rows only (theI16-inbound-capture-columnscapture above).I17-arrival-notice-inbox-row-onlyin the contract. - Session destination is configurable:
agentEmailSessionProjectPathin the channels settings schema (default empty = Agent Email project). The settings panel exposes a<Select>dropdown listing all spawnable projects. The inbound service falls back to the Agent Email project if the configured project doesn't exist. - Visibility is gated by the
agent-emailunreleased feature (UNRELEASED_PROJECT_GATESinproject-visibility.ts), not a raw settings flag. - Signed-out card + auth reliability: the panel gates on
Boolean(useGlobalAuthStore().uid). When signed out it renders an inline Sign in with Google button (desktop — runs the gate'slogin('google')) or a desktop-instruction message on a web client (!isElectron), never a dead-end pointer. The renderer's global-auth store also self-heals its hydration —fetchStatus()retriesGLOBAL_AUTH_STATUSon a bounded backoff and App re-syncs on reconnect — so a signed-in user is never stuck showing "signed out" from a raced startup fetch. See.claude/memory/contracts/global-auth-gate-contract.md→ "Renderer auth-state is self-healing". - Provisioning lives in Main (
provisionAgentEmailinsrc/main/services/email/agent-email-provisioning.ts), reached via theagent-email:provisionIPC channel with an optional{ handle }. Handle sanitization + validation are the pure helpers insrc/shared/agent-email-address.ts(shared by Main and the renderer's live preview); global uniqueness is enforced by an atomic Firestore.create(). The server domain comes from theapp_config/agent_emailconfig doc. - The preview's domain comes from Main, never a renderer literal: the input-less
agent-email:get-domainchannel (handleAgentEmailGetDomaininsrc/main/ipc/agent-email-handlers.ts) resolves it from the CLAIMED address first (which carries its own domain, no network), then the cachedagentEmailDomainsetting, then the relay'sget-config. Each success refreshes the cache, and an unresolvable domain answers''so the UI shows a placeholder rather than a wrong address. A hardcoded domain in the renderer is what once made the preview advertise a domain the mail service never used. - Setup and management are separate faces of one panel:
AgentEmailView.tsxroutes toAgentEmailSignInCard,AgentEmailSetupWizard(the three-step guided setup) orAgentEmailManagePanel(the management screen).AgentEmailSenderPolicyControlis shared by the setup's last step and the management screen so the two cannot describe the same rule differently. - Release / change is the inverse:
releaseAgentEmail(same file), via the input-lessagent-email:releaseIPC channel (and the parity CLI routePOST /agent/email/release), deletes the caller's ownagent_email_addressesdoc through the relay's uid-scopedrelease-addressop — freeing the handle (no Cloudflare action needed; the catch-all just 404s a freed address). It is idempotent ({ released:false }when there was none). The renderer gates it behind a typed-RELEASEConfirmDialogand, on success, clears the cachedagentEmailAddressso the panel drops back to the guided setup ("changing" the name is release-then-reclaim) — see.claude/memory/contracts/agent-email-contract.mdI14-release-frees-the-handle. - No shipped Firebase admin key (Phase B): the desktop's per-user Firestore access —
address lookup + the
.create()claim (provisioning) and the inbound queue read/ack (receiving) — runs through the user-tokenagentEmailRelayCloud Function (firebase/functions/src/agent-email-relay.ts, hosting rewrite/t/agent-email), which verifies the caller's Firebase ID token and scopes every op to that uid server-side. Agent Email data is private per signed-in user, so it uses the user-token relay shape (likesendEmail/shareRelay), NOT a ship-in-app deterrent token. Client seam:src/main/services/email/agent-email-relay-client.ts.user-token-relay-onlyin.claude/memory/contracts/agent-email-contract.md. - Sender policy (who may start a NEW session):
agentEmailSenderPolicy(owner/approved/anyone, defaultowner) +agentEmailApprovedSenders. The pure decision isisAgentEmailSenderAllowed(src/main/services/email/agent-email-sender-policy.ts); the hosted poller passes it to the shared inbound core as an opt-innewThreadSenderGateconsulted ONLY for new threads, so the AgentMail / bug-intake path (which passes no gate) stays byte-identical. Invariants + tests:.claude/memory/contracts/agent-email-sender-policy-contract.md. Keyed on the From address (forgeable) — a convenience gate, not a security boundary. - Screen holds share the row with sender-policy holds:
agent_email_heldgainedsource/hold_kind/hold_reason/held_message_json(migration20260924164245);hold_kindisNULLfor a sender-policy hold and one of the closedsrc/shared/screen-hold-kind.tsvalues for a safety-screen hold.held_message_jsonis the serializedHeldEmailCopy(src/main/services/email/held-email-copy.ts) — the source-shaped message an Approve replays, cleared back toNULLthe moment the row leavespending.AgentEmailHeldRow.tsxbadges a non-nullhold_kindas "Held by safety screen";HeldEmailReviewDialog.tsxrendersAgentEmailHeldList kind="screen"as a standalone surface, independent of the panel's own enabled state, so the "Emails held by the safety screen" card (SCREEN_HELD_DEDUP_KEYinemail-inbound-alerts.ts) works with Agent Email off. - Screen-hold release is owner-only, enforced twice:
approveHeldEmail/declineHeldEmail(src/main/services/email/agent-email-held-actions.ts) take areviewedByactor (AGENT_EMAIL_REVIEW_ACTOR) and refuse a screen hold for anything but.ui(the desktop IPC click) or.approvalCard(a card a person approved). The ONE axis the two actions still differ on is a spam catch:.approvalCardcannot dismiss one (assertOwnerCanRelease'sspamCatch), because clearing a catch is the spam view's job for a person in the app, while RELEASING one is allowed and runs the safety screen before any session starts. The CLI routes (POST /agent/email/held/approve|decline) never claim.ui/.approvalCardthemselves: a screen-hold approve raisesagent_email.release_screen_heldand a screen-hold decline raisesagent_email.dismiss_screen_held, and only a spam catch's decline is answered 409 outright. The card is always approval-gated, never "always allow".tests/unit/lint/held-email-release-callers.test.tspins which files may call either function at all, and which of those may claim.ui/.approvalCard. Rules:.claude/memory/contracts/email-safety-screen-contract.md. - Receiving lifecycle (does inbound actually run?): gated on
agentEmailReceivingEnabled(defaulttrue) AND a claimedagentEmailAddress— the pure predicate isshouldRunAgentEmailReceiving(settings). The poller is started/stopped byreconcileAgentEmailService(src/main/services/email/agent-email-inbound-poll.ts), idempotent via aserviceStartedlatch and driven BOTH at boot (the startup task always records the shared ProcessManager ref FIRST, so a later runtime start needs no restart) AND by asettings-applyside-effect (agent-email-receiving-reconcile) — so claiming an address or flipping the Receiving switch reconciles live. The dedicatedagentEmailReceivingEnabledflag is deliberately separate from theagentEmailEnabledin-development feature toggle (overloading it would hide the panel on pause and make the toggle deceptive). Invariants + tests:.claude/memory/contracts/agent-email-contract.md. - Poll-failure alerting (transient-blip safe): the 30s inbound poll surfaces the deduped
"some cloud features are degraded (agent email)" inbox banner only after
POLL_FAILURES_BEFORE_DEGRADED_ALERT(3) CONSECUTIVErelayPollQueuefailures (~90s); a successful poll resets the streak, so a single transient timeout never alarms the user (the server-side queue is durable and re-read next tick). Thealerts-only-on-sustained-streakinvariant in.claude/memory/contracts/firebase-health-alert-contract.md. - Connectivity self-test: the post-claim panel's "Send a test email" button calls the
input-less
agent-email:send-testIPC channel.handleAgentEmailSendTest(src/main/ipc/agent-email-handlers.ts) resolves the recipient (the signed-in account email) and thefrom(the provisioned address) in Main — the renderer sends nothing, so it only emails the user. It reuses the livesendAgentEmailpath (ownership / quota / suppression apply), mints a freshclientRequestIdper press (an input-less route must own a key per call, or the server dedupes repeats away), returns{ recipient, messageId }, and reports a dedupe that names no message as a failure, never a success. Does not touch the inbound seam. - Compose / send to anyone (the AgentMail-outbound replacement): the CLI route
POST /agent/email/send(agentEmailSendSchema,cli-server-agent-email-routes.ts) sends a BRAND-NEW email from your agent address to a caller-supplied{ to, subject, text?, html? }. Thefromis resolved server-side fromsettings.agentEmailAddress(never the body — no spoofing) and it iscliTokenOnly(a scoped agent-session token is refused, unlike the self-only send-test). It reuses the livesendAgentEmailpath, so every server-side abuse guard applies (ownership, per-tier daily quota, distinct-recipient velocity cap, suppression). It is the headless outbound half; recipients are deliberately unrestricted (send-to-anyone is the product). Contractcompose-send-to-anyone. - Answering as Support (T-88): the route takes an optional
identity: 'agent' | 'support'. Omitted (or'agent') is the send above, unchanged.'support'is for anything a person reads as a Help Desk answer — a support request, a bug report, mail to someone who wrote in — and sends From and Reply-To the install's public support address (agentEmailSupportReplyFromAddress, falling back toagentEmailSupportAddress), so such mail never leaves from the operator's own address. It names a ROLE, never an address: both addresses are resolved in Main from settings, so a body-suppliedfromis still dropped. It fails closed with400— no support address configured, or a support address that IS the agent's own address — and withidentity: 'support'combined withlistUnsubscribe(a mailing is not a Help Desk answer, the mirror of the defect). A FACE that resolves back to the agent's own address is no longer a refusal: it is not a usable public face, so it is ignored — compared through the same normalizing parser the arrival rule uses — and the support address supplies both headers, because refusing would answer a reporter who is waiting with nothing. A recipient who already owns a Help Desk ticket is unaffected: the send routes onto their ticket's thread as before and reportsidentityUsed: 'ticket-arrival', since the ticket's own identity wins. The rule lives inresolveSupportOutboundIdentity(agent-email-reply-identity.ts) — the SAME resolver the three arrival legs delegate to, so the four can never drift. Contracta-reporter-is-answered-as-support. - List mail + one-click unsubscribe: an optional boolean
listUnsubscribeon that route (and onsendAgentEmail) marks the message as mailing-list mail. ThesendEmailfunction then mints an RFC 8058List-Unsubscribe(https link) plusList-Unsubscribe-Post: List-Unsubscribe=One-Clickfor that recipient; a caller can never supply the header or link. The publicagentEmailUnsubscribefunction records the opt-out (GET only confirms; POST writes) inagent_email_suppressionsunderlist-unsubscribe-<sha256 of sender uid + recipient>, with no address or uid stored and the collection's 730-day TTL. From then onsendEmailrefuses that sender's LIST mail to that person withrecipient unsubscribed, which the route answers as422 UNPROCESSABLEwithreason: "recipient-unsubscribed". Signed with the managed secretAGENT_EMAIL_UNSUB_SECRET. ContractI23-list-mail-carries-a-relay-minted-unsubscribeandI24-an-opt-out-names-nobody. - Voice guide integration seam (Voiceprint Studio): when a calibrated Voiceprint Studio
guide exists for the default profile (
'me'),resolveEmailVoiceGuide(db)insrc/main/services/email/agent-email-voice.tsreads it and passes the markdown tobuildSessionPrompt()as an optionalvoiceGuideparameter. The prompt builder appends aREPLY VOICEsection that tells the agent to match that writing style — but explicitly subordinates the voice guide to the HARD RESTRICTIONS and REPLY FORMAT rules. When no guide exists (the default while Voiceprint Studio is in-development), the parameter isnulland the prompt is byte-identical to the pre-seam version. The guide is capped at 4000 chars. Invariant I15 in.claude/memory/contracts/agent-email-contract.md.
Related
The other built-in Agent Tools integrations are described in Agent tools, and the bearer-token contract behind every route above lives in CLI Control.
Last verified 2026-10-10