Makes the agent reachable by email: it polls a mailbox over IMAP and answers over SMTP. The connector that fits work that arrives as a request rather than a chat.
Verified against fakes only. The IMAP/SMTP adapter is covered by the shared channel contract suite but has never been run against a real mailbox. Expect rough edges, and please record them in
internal/roadmap.md.
Configure
All five are required — the connector needs a mailbox to poll and an address to answer from:
export EMAIL_IMAP_HOST=imap.example.com
export EMAIL_SMTP_HOST=smtp.example.com
export EMAIL_USER=agent@example.com
export EMAIL_PASS=...
export EMAIL_FROM=agent@example.com
bun packages/cli/src/index.ts my-space serve
Ports default to IMAP 993 and SMTP 465, both TLS. Override with EMAIL_IMAP_PORT /
EMAIL_SMTP_PORT — but TLS isn't per-variable configurable, so a plaintext or STARTTLS-only
server won't work as-is.
IMAP and SMTP share the one credential pair. With a provider enforcing 2FA, use an app-specific password.
Behavior
A reply chain is a conversation. The key is derived from the root of References, falling back
to In-Reply-To, falling back to the message's own id for a fresh thread. So a thread keeps one
session however deep it goes, and the connector and normalizer agree on the key.
Polling, not push. The mailbox is polled for unseen messages every 30 seconds by default; each one is decoded and marked seen. There's no IMAP IDLE, so worst-case latency is the poll interval plus the turn.
Bodies are cleaned up before the model sees them. Multipart mail prefers text/plain and falls
back to tag-stripped text/html. Quoted history from the previous message is stripped, so only
the new text reaches the agent.
Subject is folded in only on a thread's first message — a reply's session already carries the
topic. Replies get Re: prefixed, without doubling it.
Replies are chunked at 20,000 characters.
Buttons degrade to text, label and id both — - Approve → reply "approve:a1b2" — so a
decision, or any option the agent offers in a ```send-buttons block
(authoring them), is chosen by
replying with the id rather than clicking.
Email cannot receive an unprompted post. A deliver: target on an email: conversation
has no connector to go out on, so it is logged and skipped.
Attachments: named coming in, real going out
An attachment becomes pointer text in the prompt:
[attachment: quarterly.pdf]
The bytes are never fetched. Unlike Telegram, Email has no okf/scratch/inbound/ materialization
at all, so the agent knows a file was attached and cannot open it. If the work depends on file
contents, have the sender paste them inline. Tracked in
internal/roadmap.md.
Going out, MIME carries files natively: the agent names them in a fenced send-files block
(one workspace-relative path per line) and they ride along on the first mail of the reply — a
chunked reply never sends duplicate copies. Paths must stay inside the workspace. See
Telegram for the block's shape.
Operational cautions
Every unseen message is a turn. Point this at a dedicated mailbox, not a human's inbox — newsletters, automated notifications, and spam all become turns, each spending tokens with no budget ceiling to stop them (permissions).
There is no loop guard. Nothing filters mail from the agent's own address, and replies go to
whatever From arrived — so if the agent's output can reach its own mailbox (a list, an alias, an
auto-forward), it will answer itself. Keep EMAIL_FROM off any path that routes back to
EMAIL_USER.
Marking seen happens as messages are decoded, so a crash mid-turn can leave a message marked seen but unanswered.
One agent per process
serve passes no channel to agent selection, so the default: true agent answers everything
regardless of channels:. Two agents means two processes — and two mailboxes.