Omniscio documentation
Browse all documentation
  1. Getting Started17
  2. Sessions & Agents132
  3. Inbox & Notifications67
  4. Projects & Tasks96
  5. Automation & Scheduling78
  6. Knowledge & Memory27
  7. AI Features71
  8. Integrations106
  9. Plugins & Marketplace34
  10. Cloud & Teams59
  11. Settings & Customization65
  12. Account & Billing28
  13. Troubleshooting79
  14. CLI & API Reference26
  15. Legal & Policies5
  16. Uncategorised17

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 an amc-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 uses useProjectSessionHost + SessionHostSidebar; the Emails tab lists received emails via the agent-email:list-emails IPC 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 (buildEmailPreview in src/main/services/agentmail-parsing.ts) are stamped into email_inbound_tracking (nullable from_address / subject / preview columns, ledger migration 20260812193000) at the create AND continue seams via trackInboundMessage(…, meta); listAgentEmails returns 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-columns in .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) raises raiseEmailArrivalAlert (src/main/services/email/email-inbound-alerts.ts) — a quiet raiseAgentAlert inbox row (DB insert + ALERT_CREATED only, so NO OS notification/sound; survives Focus Mode as the durable record). Per-message dedupKey email-arrived:<messageId> (one row per email, updates-in-place), sourceSessionId for 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-type emailArrivalNotificationsEnabled setting (default on, toggled in the panel) at the call site — so email-inbound-alerts.ts stays a pure leaf. The bug-intake spawnBugReportSession path never calls it. The card carries the whole cleanEmailBody output (de-quoted, 16k-capped there) via the body param, passed through flowHardWrappedEmail (src/main/services/email/email-body-flow.ts, spec tests/unit/services/email-body-flow.test.ts) so the sender's client wraps become paragraphs — NOT buildEmailPreview, whose 140-char cut and whitespace-collapsing turned the card into a fragment; buildEmailPreview belongs on list rows only (the I16-inbound-capture-columns capture above). I17-arrival-notice-inbox-row-only in the contract.
  • Session destination is configurable: agentEmailSessionProjectPath in 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-email unreleased feature (UNRELEASED_PROJECT_GATES in project-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's login('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() retries GLOBAL_AUTH_STATUS on 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 (provisionAgentEmail in src/main/services/email/agent-email-provisioning.ts), reached via the agent-email:provision IPC channel with an optional { handle }. Handle sanitization + validation are the pure helpers in src/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 the app_config/agent_email config doc.
  • The preview's domain comes from Main, never a renderer literal: the input-less agent-email:get-domain channel (handleAgentEmailGetDomain in src/main/ipc/agent-email-handlers.ts) resolves it from the CLAIMED address first (which carries its own domain, no network), then the cached agentEmailDomain setting, then the relay's get-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.tsx routes to AgentEmailSignInCard, AgentEmailSetupWizard (the three-step guided setup) or AgentEmailManagePanel (the management screen). AgentEmailSenderPolicyControl is 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-less agent-email:release IPC channel (and the parity CLI route POST /agent/email/release), deletes the caller's own agent_email_addresses doc through the relay's uid-scoped release-address op — 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-RELEASE ConfirmDialog and, on success, clears the cached agentEmailAddress so the panel drops back to the guided setup ("changing" the name is release-then-reclaim) — see .claude/memory/contracts/agent-email-contract.md I14-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-token agentEmailRelay Cloud 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 (like sendEmail/shareRelay), NOT a ship-in-app deterrent token. Client seam: src/main/services/email/agent-email-relay-client.ts. user-token-relay-only in .claude/memory/contracts/agent-email-contract.md.
  • Sender policy (who may start a NEW session): agentEmailSenderPolicy (owner/approved/anyone, default owner) + agentEmailApprovedSenders. The pure decision is isAgentEmailSenderAllowed (src/main/services/email/agent-email-sender-policy.ts); the hosted poller passes it to the shared inbound core as an opt-in newThreadSenderGate consulted 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_held gained source / hold_kind / hold_reason / held_message_json (migration 20260924164245); hold_kind is NULL for a sender-policy hold and one of the closed src/shared/screen-hold-kind.ts values for a safety-screen hold. held_message_json is the serialized HeldEmailCopy (src/main/services/email/held-email-copy.ts) — the source-shaped message an Approve replays, cleared back to NULL the moment the row leaves pending. AgentEmailHeldRow.tsx badges a non-null hold_kind as "Held by safety screen"; HeldEmailReviewDialog.tsx renders AgentEmailHeldList 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_KEY in email-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 a reviewedBy actor (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: .approvalCard cannot dismiss one (assertOwnerCanRelease's spamCatch), 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/.approvalCard themselves: a screen-hold approve raises agent_email.release_screen_held and a screen-hold decline raises agent_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.ts pins 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 (default true) AND a claimed agentEmailAddress — the pure predicate is shouldRunAgentEmailReceiving(settings). The poller is started/stopped by reconcileAgentEmailService (src/main/services/email/agent-email-inbound-poll.ts), idempotent via a serviceStarted latch 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 a settings-apply side-effect (agent-email-receiving-reconcile) — so claiming an address or flipping the Receiving switch reconciles live. The dedicated agentEmailReceivingEnabled flag is deliberately separate from the agentEmailEnabled in-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) CONSECUTIVE relayPollQueue failures (~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). The alerts-only-on-sustained-streak invariant 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-test IPC channel. handleAgentEmailSendTest (src/main/ipc/agent-email-handlers.ts) resolves the recipient (the signed-in account email) and the from (the provisioned address) in Main — the renderer sends nothing, so it only emails the user. It reuses the live sendAgentEmail path (ownership / quota / suppression apply), mints a fresh clientRequestId per 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? }. The from is resolved server-side from settings.agentEmailAddress (never the body — no spoofing) and it is cliTokenOnly (a scoped agent-session token is refused, unlike the self-only send-test). It reuses the live sendAgentEmail path, 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). Contract compose-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 to agentEmailSupportAddress), 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-supplied from is still dropped. It fails closed with 400 — no support address configured, or a support address that IS the agent's own address — and with identity: 'support' combined with listUnsubscribe (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 reports identityUsed: 'ticket-arrival', since the ticket's own identity wins. The rule lives in resolveSupportOutboundIdentity (agent-email-reply-identity.ts) — the SAME resolver the three arrival legs delegate to, so the four can never drift. Contract a-reporter-is-answered-as-support.
  • List mail + one-click unsubscribe: an optional boolean listUnsubscribe on that route (and on sendAgentEmail) marks the message as mailing-list mail. The sendEmail function then mints an RFC 8058 List-Unsubscribe (https link) plus List-Unsubscribe-Post: List-Unsubscribe=One-Click for that recipient; a caller can never supply the header or link. The public agentEmailUnsubscribe function records the opt-out (GET only confirms; POST writes) in agent_email_suppressions under list-unsubscribe-<sha256 of sender uid + recipient>, with no address or uid stored and the collection's 730-day TTL. From then on sendEmail refuses that sender's LIST mail to that person with recipient unsubscribed, which the route answers as 422 UNPROCESSABLE with reason: "recipient-unsubscribed". Signed with the managed secret AGENT_EMAIL_UNSUB_SECRET. Contract I23-list-mail-carries-a-relay-minted-unsubscribe and I24-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) in src/main/services/email/agent-email-voice.ts reads it and passes the markdown to buildSessionPrompt() as an optional voiceGuide parameter. The prompt builder appends a REPLY VOICE section 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 is null and 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