---
title: Agent Email
---

# Agent Email

## What it is

Give your agent its own real email address. People (or other systems) can email it to
**start or continue a session**, and your agent can **reply back out** — powered by the
Cloudflare-backed email service Omniscio runs (`amcmailbox.com`).

## Where to find it

### Where it lives

Agent Email is a sidebar entry nested under **Omniscio → Agent Tools** (alongside CLI Tools,
Skills, MCP Servers, and API Keys). Click the **Agent Email** row to open
its panel.

It is an **in-development** feature, so it ships hidden. Reveal it on a machine via
**Settings → Lab → "Agent Email"** (or the `AMC_SHOW_AGENT_EMAIL=1` env var in a dev
build). Until revealed, the sidebar row does not appear. There is also a slim
**Settings → Agent Email** screen, but it only holds the on/off toggle — all claiming and
managing happens in the sidebar panel.

Agent Email is the **primary, zero-setup way to receive email into Omniscio**. The older "Email
Inbound" path (forward mail in from your own AgentMail inbox) still exists, but is now framed
as an **"Advanced — use your own inbox"** option under **Settings → Email & Summaries** — the
same capability, for people who want to bring their own inbox.

### Claiming an address (you pick the name)

Before you have an address, the panel shows a name field:

1. **Type the name you want** — the part before the `@`. As you type, a live preview shows
   the full address, e.g. `your-agent` → **`your-agent@amcmailbox.com`**. The preview is exactly
   what you'll get: letters, numbers, and hyphens only (spaces/punctuation become hyphens,
   everything lowercases).
2. **Instant validation** — the name must be at least 3 characters and not a reserved word
   (`admin`, `abuse`, `postmaster`, `support`, `no-reply`, `bounces`, …). The Claim button
   stays disabled with a short reason until the name is valid.
3. **Claim it** — the address is **globally unique** across everyone on the shared domain.
   If someone already took that name, you get a clean _"That name is already taken — please
   choose another"_ and pick a different one (it does **not** silently append random
   characters).

Claiming is idempotent: one address per account, so re-opening the panel always shows the
same address. If you picked the wrong name — or want to give the address up — the panel's
**"Change or release address"** action (at the bottom, once claimed) releases it. Because
releasing is irreversible, it asks you to type **RELEASE** to confirm; then the address is
deleted, the handle frees up, and you drop back to the "pick a name" form to claim a new
one. **Releasing is permanent** — once freed, the name is available for anyone to claim and
mail to the old address stops working — so re-claiming a different name is how you "change"
it (there is no in-place rename).

Once claimed, the panel also shows a short **"How it works"** explainer (share the address →
mail to it starts a session → your agent replies by email) and a one-click **"Send a test
email"** button. The test sends a real message from your agent address to your own account
inbox — the recipient is resolved server-side from your signed-in account, so it can only
ever email you, never a stranger — letting you confirm that sending actually reaches an inbox.

You must be **signed in to your Omniscio account** (Google) to claim — the address is
tied to your account. When signed out, the panel shows a real **Sign in with Google** button
right there (the same Google sign-in the app's sign-in gate uses) on the desktop, or a "sign in
on your computer" note on the web/mobile client (the OAuth opens a desktop browser and can't be
completed from a phone) — never a dead-end pointer to go find another screen.

### Who can email it (receiving setup)

**Receiving turns on the moment you claim an address — no restart, no extra step.** The panel
has a plain **Receiving** on/off switch (on by default) so you can pause incoming mail at any
time and turn it back on later, all live. Pausing keeps your address; it just stops new mail
from starting sessions. (This switch is honest — it actually controls the inbound checker,
unlike the in-development feature toggle, which only reveals the panel.)

Right where you claim the address, a **"Who can email it"** control decides who may start a
NEW session by emailing it. It defaults to the safest choice and is set in the same panel:

- **Only me** (default) — only mail from your own account email starts a session. The panel
  shows exactly which address that is.
- **People I approve** — add specific email addresses; only those (plus you) get through.
- **Anyone** — open intake. Anyone who learns the address can start a session (the AI
  safety screen still applies, and so does the spam filter if you have turned it on). Shown with a "keep this restricted" caution.

**Replies always get through.** The policy gates only _brand-new_ conversations — a reply in a
thread your agent already started is always accepted, whatever the setting, so the agent can
hold a back-and-forth it began. Each inbound email that starts a session uses your AI usage
like any other session (the panel says so).

> **"Only me" is a convenience lock, not hard security.** An email's From address can be
> forged, so this is a friendly filter — the real protection is server-side sender checks
> (SPF/DKIM) plus the AI safety screen, and spawned sessions are sandboxed. The UI copy says so.

## How it behaves

### Held for review (blocked senders — and the safety screen — are captured, never dropped)

When someone the policy DOESN'T allow (a stranger under "Only me") emails your address, their
message is **not** thrown away — it is **held for your review**. The same tray also catches any
email, on any route, that the built-in [safety screen](email-inbound-prescreen.md) flagged or
could not check; those rows carry a **"Held by safety screen"** badge, the screen's
plain-language reason, and which route the mail came in on, so you can tell the two kinds of hold
apart at a glance. An email the [spam filter](spam-filter.md) caught is held too, but it waits in
the Inbox's Spam list instead of here.

The **"Held for review"** tab in the Agent Email panel lists every held email — sender, subject,
and a short preview for a blocked-sender row, plus the badge and reason for a safety-screen row —
so you can see exactly what's waiting and decide, instead of the mail vanishing. The queue stays a
list down the left; picking one opens that email in the pane beside it, the way a mail app works.
A single quiet
inbox notice tells you how many blocked-sender emails are waiting (it updates in place — never one
ping per email), with a **"Review emails"** button that opens that Held-for-review tab in one
click — on your phone as well as the desktop. Safety-screen holds get their OWN separate **"Emails
held by the safety screen"** card with a **"Review held emails"** button that opens a standalone
review dialog listing just those, laid out the same way — the list with the email beside it, or one
at a time on a phone — and because that dialog talks to the held-email list directly, it works even
with the Agent Email panel switched off. A message that fails sender authentication
(DMARC) — a possible spoofed "from yourself" — is still captured but shown with an **"Unverified"**
badge so it is never mistaken for the real sender.

This is the honest middle ground between "Only me" (which used to silently drop everyone else)
and "Anyone" (open intake): strangers wait in a review tray you clear, and nothing is ever lost.

#### Reading and clearing the tray

Click a held email to open it in the pane beside the list and read the **whole message**, not just
the preview line. On a phone the email takes the screen instead — one half at a time — and carries
a **Back** control to return to the queue, which is also where you land once you have dealt with
it. Two buttons then sit under the message:

- **Approve & start session** — lets that email through. A session starts from it exactly as if the
  sender had been allowed all along, your agent works on it and replies by email, and the app jumps
  you straight to that session. The safety screen does not run a second time on a message you
  approved, since you've now looked at it yourself — which holds for a blocked-sender email exactly
  as for one the screen itself held. An email an AGENT approves over the command line is not you
  looking at it, so that one is still screened. From then on the conversation is a normal
  thread: their reply continues the same session and is never held again.
- **Dismiss** — takes it off the list. For an ordinary blocked-sender hold, **nothing is sent to
  the sender** — deliberately, because replying "rejected" would tell a spammer the address is
  real and read by a person. A safety-screen hold is different: dismissing one asks you to confirm
  first, then emails the sender that their message couldn't be processed (and tells you if even
  that notice failed to send). Either way, the message itself is kept, not deleted, until you act.

**Agents can only ask, never decide.** If an agent working through the command line asks to
release an email the safety screen held, it raises an approval card for you instead of releasing
it — the same as any other high-risk action — and a command-line dismiss of one is refused
outright. Nothing holding the app's own command-line key can release or drop a safety-screen hold
except you, in the app.

The "N waiting for review" notice updates itself after each decision and disappears once the tray
is empty.

Two things worth knowing:

- **Attachments and reply headers come along.** Approving replays the message as it originally
  arrived — its reply headers and any attachments, not just the text — so the resulting session
  sees what was actually held and its first reply threads correctly (an email held before this
  shipped may still replay without its attachments, since no copy of the original was kept for
  those older rows). The stored text is cut at 256 KB, so an unusually long body is held — and
  later released — cut at that length; attachments themselves are not shortened.
- **Approving is desktop-only.** On a phone you can read a held email and dismiss it, but the
  Approve button is hidden — approving starts a real (paid) session, and that class of action is
  kept off the mobile bridge on purpose.

Approving one email does not add the sender to your approved list — a *new, unrelated* email from
them is held again. If you want someone through permanently, switch "Who can email it" to
**Approved senders** and add their address.

### How mail flows

- **Inbound**: mail sent to your `@amcmailbox.com` address arrives via Cloudflare Email
  Routing → an email worker → a Firebase function that drops it into an inbound queue Mission
  Control picks up. The inbound checker runs whenever you have a claimed address AND the
  **Receiving** switch is on — claiming (or flipping the switch) starts/stops it immediately,
  with no restart. A message to a known address **starts or continues a session**; mail to a
  random/unknown address on the domain is rejected with a bounce by design. Before a NEW
  session spawns, the **"Who can email it" policy** (above) is applied — a sender it blocks is
  **held for your review** (captured in full, not silently dropped — see "Held for review" below;
  no session, no reply is sent); a reply on an existing thread skips the check.
- **Outbound**: your agent can reply in a thread it's already in, OR **compose a brand-new email
  to anyone** from its address (the latter via the `POST /agent/email/send` CLI route — the
  Omniscio-native replacement for a third-party outbound sender like AgentMail). Outbound has abuse
  guards — per-account daily limits, a distinct-recipient-per-day cap, no spoofing (the `from` is
  always your own agent address, set server-side), and auto-suppression of recipients that bounce
  or complain (to protect the shared domain's reputation).

### Seeing your mail + arrival alerts

The Agent Email panel's **Emails** tab is a real inbox view: each row shows the **sender, the
subject, a short preview** of the message, and the date it arrived (older emails that predate
this fall back gracefully to the session name). Click a row to open the session it started.

When an email arrives and **starts or continues a session**, a quiet entry also appears in your
**main Omniscio inbox** — _"New email from &lt;sender&gt;"_ with the subject and **the full
message** — so you can read the email right there instead of opening the session to find out what
it said. Quoted reply history and signatures are stripped, and very long mail is capped, but the
message itself arrives whole. Lines the sender's mail program wrapped are flowed back into full
paragraphs, so a wrapped paragraph reads as one line, while the lines the sender typed — a
greeting, a sign-off under a finished sentence, a list, a forwarded message's header, a pasted log
or code block — keep their own line; mail that was never wrapped is normally left exactly as sent.
It is a calm inbox row, **not** an OS
pop-up or a sound, and it links straight to the session. Bug reports / feedback emails do **not**
raise it (they have their own triage flow). A **"Notify me in the inbox"** switch in the panel
turns it off (default on), and your global notification setting still gates it.

## 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.
- **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 reverts to the claim form. "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, for approve only, `.approvalCard`. The CLI routes
  (`POST /agent/email/held/approve|decline`) raise the `agent_email.release_screen_held`
  approval card for a screen-hold approve and answer 409 for a screen-hold decline — they never
  claim `.ui`/`.approvalCard` themselves — and 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 BOTH the recipient (the signed-in
  account email, `getCachedAuth().email`) and the `from` (the provisioned
  `settings.agentEmailAddress`) in Main — the renderer sends nothing, so the test can only
  email the user themselves. It reuses the live `sendAgentEmail` path (server-side
  ownership / per-tier daily quota / recipient-suppression all still apply) with a
  `clientRequestId` for idempotency, returns `{ recipient }` for the UI confirmation, and
  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`.
- **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](agent-tools.md), and the bearer-token contract behind every route above lives in [CLI Control](cli-control.md). An agent that wants to email someone other than you is the same command-line pattern [Agent-driven sessions](agent-driven-sessions.md) uses to drive a session over HTTP. Bug reports and feedback emails deliberately take a different path ([Bug report intake](bug-report-intake.md)) and never raise the arrival notice described above. What the safety screen reads before any of this, why it holds rather than bounces, and how its picture/attachment limits work is covered in [Email inbound prescreen](email-inbound-prescreen.md).
