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 configured
  • bucket — S3 bucket where SES delivers inbound email
  • prefix — S3 key prefix (SES receipt rules typically use a prefix like incoming/)
  • from — The sender address for outbound replies
  • allowed-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

  1. The email metadata middleware starts a polling loop via email-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.

  2. 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-send replies 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
  3. 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:

  1. It iterates through routes in order, matching from and subject patterns against the inbound message using glob patterns. boss@* matches any domain. *@example.com matches any user at that domain. * matches everyone.
  2. The first matching route wins. The skill named in ref is 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 email middleware 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-receive without 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-wait with gateway-message enables observation patterns. Skills can observe inbound messages via the event system without affecting route dispatch. Use event-subscribe + event-wait on the gateway-message topic for buffered, non-consuming observation.

Next Steps

Ask AI