Tutorial: Build an Email Agent
Build a long-running email assistant that polls for inbound email via Amazon SES and S3, responds via SES, and routes different senders to different skills. By the end you'll understand how the email middleware manages the polling lifecycle, how routes match inbound messages to skills, how channels abstract the reply transport, and how persistent mode keeps the agent running indefinitely.
What You'll Build
An email assistant that:
- Polls an S3 bucket for inbound email delivered by SES
- Responds to emails via SES automatically
- Routes different senders to specialized handler skills
- Runs persistently, processing emails as they arrive
Prerequisites
- Node.js 22+
- AWS credentials with SES and S3 access
- A verified SES domain with inbound email configured — SES delivers incoming email to an S3 bucket. See Amazon Internal Setup for SuperNova domain setup, or the SES documentation for general setup.
- An S3 bucket receiving inbound email from SES
- AWS credentials with Bedrock access — see AWS & Bedrock Setup
Step 1: Create the Project
mkdir email-agent && cd email-agent
agent-apps init
Update main.skill.md:
---
name: email-agent
description: Email assistant that responds to inbound messages
allowed-tools: "*"
metadata:
role: main
paths: [./skills]
model:
id: us.anthropic.claude-sonnet-4-6
region: us-east-1
persistent: true
email:
region: us-east-1
bucket: my-email-bucket
prefix: incoming/
from: agent@mydomain.com
allowed-senders: []
poll-interval: 30s
trust:
rules: "file-write shell"
store: file
---
You are an email assistant. Read incoming messages and respond helpfully.
What Each Config Key Does
persistent: true — Keeps the agent running indefinitely. Without this, the agent would process one email and exit. With persistent mode, it loops: poll → process → respond → poll → ...
email — Configures the email transport:
region— AWS region where SES and S3 are configuredbucket— S3 bucket where SES delivers inbound emailprefix— S3 key prefix (SES receipt rules typically use a prefix likeincoming/)from— The sender address for outbound repliesallowed-senders: []— Empty array means accept email from anyone. Add addresses to restrict:["alice@example.com", "bob@example.com"]poll-interval: 30s— How often to check S3 for new messages
trust — Gates dangerous tools. When the agent tries to use file-write or shell, it prompts for approval. Since this is an email agent, the approval prompt goes to the email thread — the user replies with y, a, n, or v.
Step 2: Run It
agent-apps
You'll see:
Email listener started: inbox (SES/S3, polling every 30s)
What Happens Under the Hood
The
emailmetadata middleware starts a polling loop viaemail-listen-ses. This is a callback-ref-based lifecycle — the middleware creates a callback, passes it to the listener, and the listener invokes it for each message.Every 30 seconds, the listener checks S3 for new objects under
incoming/. When it finds one, it:- Downloads the raw MIME message from S3
- Parses it (headers, body, attachments)
- Sets up a gateway so
gateway-sendreplies via SES:nonlocals.gateway = { name: "inbox", description: "Email from alice@example.com", send: "inbox-send-a1b2c3d4", // callback ref → sends via SES receive: "inbox-receive-e5f6g7h8", // callback ref → waits for reply id: "alice@example.com" } - Invokes the main skill (or a route-matched skill) with the email content as args
- Deletes the S3 object after processing
The agent reads the email, reasons about it, and responds. When it calls
gateway-send, the gateway dispatches to the send callback ref, which sends via SES. The agent doesn't know it's replying to an email — it just uses the gateway.
Send a test email to agent@mydomain.com:
From: you@example.com
Subject: Hello
Hi! What can you do?
After up to 30 seconds (the poll interval), you'll see:
Inbound email: inbox — from you@example.com, subject "Hello"
⠋ Thinking...
✓ gateway-send — Completed in 1.2s
And you'll receive a reply email from agent@mydomain.com.
Step 3: Route by Sender
Instead of one skill handling all emails, you can route different senders to specialized handlers. Add routes to the email config in main.skill.md:
metadata:
email:
region: us-east-1
bucket: my-email-bucket
prefix: incoming/
from: agent@mydomain.com
allowed-senders: []
poll-interval: 30s
routes:
- ref: vip-handler
from: "boss@*"
- ref: general-handler
from: "*"
Create skills/vip-handler.skill.md:
---
name: vip-handler
description: Handle emails from VIP senders with priority
allowed-tools: "file-read file-write shell"
---
This is a high-priority email from a VIP sender. Read it carefully, take any requested actions immediately, and respond promptly with a detailed confirmation of what you did.
Create skills/general-handler.skill.md:
---
name: general-handler
description: Handle general emails
allowed-tools: "file-read"
---
Read the email and respond helpfully. Keep it brief — one paragraph max.
How Email Routing Works
When an email arrives, the email middleware checks the routes array in the email config:
- It iterates through routes in order, matching
fromandsubjectpatterns against the inbound message using glob patterns.boss@*matches any domain.*@example.commatches any user at that domain.*matches everyone. - The first matching route wins. The skill named in
refis invoked.
If no route matches, the email is logged and skipped.
Each route skill gets its own gateway set up by the email middleware, so gateway-send in any handler replies to the correct sender via email.
The email middleware also enriches the skill's prompt with context:
This skill handles inbound email on profile "inbox".
Sender: boss@example.com
Subject: Q3 Report
Reply-to: boss@example.com (via email-send)
To reply, use gateway-send. To not reply, return without calling gateway-send.
This context is prepended to the skill's prompt so the agent knows who sent the email and how to reply.
Step 4: Send Email from Code
You can send email programmatically from any skill:
// Direct send — you specify the recipient
await ctx.manager.invoke('email-send', {
to: 'user@example.com',
subject: 'Report Ready',
body: 'Your weekly report is attached.',
from: 'agent@mydomain.com'
});
// Channel send — replies to whoever triggered the current pipeline
await ctx.manager.invoke('gateway-send', { message: 'Got it, working on it...' });
The difference: email-send is a direct SES call where you specify everything. gateway-send dispatches through the gateway, which the email middleware set up with the sender's address as the reply-to. Use gateway-send when replying to an inbound message; use email-send when initiating a new email.
Step 5: Wait for Follow-Up Messages
Sometimes the agent needs to ask a clarifying question and wait for the reply. Use gateway-receive:
// In agent code:
await ctx.manager.invoke('gateway-send', { message: 'Which report do you want — weekly or monthly?' });
const reply = await ctx.manager.invoke('gateway-receive', { prompt: 'Waiting for reply...' });
// reply contains the user's next email
Under the hood, gateway-receive dispatches to email-receive, which polls a shared message queue populated by the email listener. When the next email arrives from the same sender, email-receive returns the message content. Note that route dispatch and gateway-receive are independent — the same email is delivered to both the route handler (if matched) and the receive queue. See Observing Gateway Messages for the full picture.
This is the same pattern used in the CLI (where gateway-receive reads from stdin) and Slack (where it waits for the next thread message). The agent code is identical across all transports.
What You Built
email-agent/
main.skill.md ← Main skill: email config + fallback handler
skills/
vip-handler.skill.md ← Priority handler for VIP senders
general-handler.skill.md ← Default handler for everyone else
.agent-apps/
trust.json ← Trust decisions (created by trust middleware)
What You Learned
- The
emailmiddleware manages the full polling lifecycle. It starts a listener that polls S3, parses MIME, sets up the gateway, dispatches to skills, and cleans up S3 objects. - Email
routes:matches inbound messages to skills. Sender and subject patterns (glob). First match wins. Unmatched emails fall through to the main skill. - Channels abstract the reply transport. The agent uses
gateway-send/gateway-receivewithout knowing it's email. The email middleware sets up a gateway with the sender's address, so replies go to the right person. - Persistent mode keeps the agent polling. Without
persistent: true, the agent would process one email and exit. - Trust works over email. When the agent needs approval for a dangerous tool, it sends the trust prompt as an email and waits for the user's reply.
event-waitwithgateway-messageenables observation patterns. Skills can observe inbound messages via the event system without affecting route dispatch. Useevent-subscribe+event-waiton thegateway-messagetopic for buffered, non-consuming observation.
Next Steps
- Slack Bot Tutorial — Same patterns, different transport
- Automation Tutorial — Webhooks, subagents, events, scheduling
- Amazon Internal Setup — SES/S3 setup with SuperNova domains
- Cookbook — Email — Email configuration details