Looped Docs

Email

Wake the agent on inbound email, pushed by Resend webhooks or pulled from a mailbox over IMAP, Gmail or Outlook; replies go back out in the same thread.

An email address is the one inbox every business already has. Invoices land there, support requests land there, statements from providers land there. The email trigger lets incoming mail wake an agent: each qualifying message becomes a run, and the agent's answer goes back out as a reply in the same thread.

triggers:
  - type: email
    transport: resend
    from_addresses: ["ratul@example.com", "*@example.com"]  # required
    # path: /email                            (default)
    # port: 8080                              (default)
    # signing_secret_env: RESEND_WEBHOOK_SECRET (default)
    # api_key_env: RESEND_API_KEY             (default)
    # allow_silence: true                     # a reply of exactly __NO_REPLY__ sends nothing

transport says how mail arrives, and there are two shapes. resend is the pushed transport: Resend receives mail for your domain and POSTs each message to the agent as a signed webhook. The other three are pulled transports that watch a mailbox that already exists: imap for anything self-hosted or app-password friendly, and gmail / outlook for the two big hosted providers via their APIs (Watching a mailbox). Use resend to give an agent its own address; use a pulled transport to put an agent on mail you already receive.

Setting up the Resend transport

You need a domain (or a subdomain like agents.example.com) whose inbound mail you're willing to point at Resend, and a public HTTPS endpoint for the agent. Unlike the chat triggers, which connect outward, a webhook has to be reachable from the internet, so put the agent behind your reverse proxy or a tunnel.

  1. In Resend, add your domain and set up receiving: the MX records they give you route the domain's inbound mail to them.
  2. Create a webhook for the email.received event, pointed at your agent's URL (e.g. https://agents.example.com/email). Copy the signing secret (whsec_…) into RESEND_WEBHOOK_SECRET.
  3. Create an API key with sending access and put it in RESEND_API_KEY. The trigger uses it to fetch message bodies and to send replies.
  4. List who may write to the agent in from_addresses, and start it.

Both env vars resolve at startup, and a missing one fails the boot right there, before the first message.

triggers:
  - type: email
    transport: resend
    from_addresses: ["*@example.com"]
configs:
  agent-yaml:
    content: |
      handle: invoice-inbox
      description: Files invoices that arrive at invoices@example.com.
      model:
        provider: openai-compatible
        id: gpt-5.4-mini
      purpose: |
        Mail arriving here is an invoice or it isn't. If it is, extract
        the vendor, amount, currency and due date, and record them.
        Reply with a one-line confirmation. If it isn't an invoice,
        reply with exactly __NO_REPLY__.
      triggers:
        - type: email
          transport: resend
          from_addresses: ["*@example.com"]
          allow_silence: true

services:
  invoice-inbox:
    image: ghcr.io/loopedautomation/agent:latest
    configs:
      - source: agent-yaml
        target: /agent/agent.yaml
    env_file: .env   # RESEND_WEBHOOK_SECRET, RESEND_API_KEY, model API key
    ports:
      - "8080:8080"  # put a TLS-terminating proxy in front
    volumes:
      - invoice-inbox-data:/data
    restart: unless-stopped
volumes:
  invoice-inbox-data:

What the agent sees, and how it answers

Resend's webhook carries the message metadata; the trigger fetches the body through the Resend API and renders the whole thing into plain text: from, to, subject, date and the body. Plain-text bodies pass through as they are. HTML-only mail gets a naive tag-strip, which is fine for correspondence and will mangle the occasional newsletter. Attachments are listed by filename and size and are otherwise dropped for now; delivering their contents needs a story about writable paths and size caps first.

Replies are ordinary email sends through the same API. They go out from the address the mail arrived on, back to the sender (or the Reply-To if one is set), with In-Reply-To and References pointing at the original so mail clients thread them correctly, and the conventional Re: on the subject.

Conversations are keyed by the thread's root Message-ID, so with memory.scope: thread (Memory) an ongoing correspondence loads its history the same way a Discord thread does. Mail without threading headers falls back to subject plus sender.

One mechanical difference from the webhook trigger: the HTTP response doesn't carry the run result. The trigger acknowledges the webhook immediately and runs the agent afterwards, because Resend retries slow endpoints and a retried webhook would mean a duplicate run. The reply email is the delivery channel; the run also lands in the run history as always.

Watching a mailbox: the pulled transports

The pulled transports sign into a mailbox and poll it. No public endpoint, no MX records - the trigger connects outward like the chat triggers do, checks for new mail on an interval and backs off on errors. All three share the same mechanics: the sender and auto-mail filters run before the model is called, the rendered input and threading are the same as above, and credentials resolve at startup so a bad password or expired token fails the boot.

The mailbox is the cursor. The trigger fetches what the mailbox marks unseen (IMAP) or unread (Gmail, Outlook) and clears that mark when it's done - after the run for handled messages, immediately for dropped ones, since an unread message it will never act on would otherwise be refetched every poll. A restarted container picks up where it left off with no local state, and a crash between the run and the mark costs one duplicate run. The consequence to plan around: the agent shares read-state with any human in the same mailbox, and whoever opens a message first wins. Give the agent a folder or label it owns, with mail routed in by your own filters.

IMAP

triggers:
  - type: email
    transport: imap
    host: imap.fastmail.com
    username: agent@example.com
    # password_env: IMAP_PASSWORD  (default)
    smtp_host: smtp.fastmail.com   # replies go out over SMTP
    # port: 993 / smtp_port: 465   (defaults, implicit TLS)
    # folder: INBOX                (default)
    # poll_seconds: 60             (default)
    from_addresses: ["happy@example.com", "gwinyai@example.com"]

This covers any provider that still issues passwords: self-hosted mail, Fastmail app passwords, and Gmail through an app password (requires 2-step verification on the account). Replies go out over SMTP from username, threaded with In-Reply-To and References. Outlook is the exception - Microsoft requires OAuth even for IMAP, so use the outlook transport instead.

The MIME parsing here is deliberately small: plain and HTML bodies, base64 and quoted-printable, RFC 2047 subjects. Ordinary correspondence parses fine; an exotic newsletter may render roughly.

Gmail

triggers:
  - type: email
    transport: gmail
    client_id: 1234-abc.apps.googleusercontent.com
    # client_secret_env: GMAIL_CLIENT_SECRET   (default)
    # refresh_token_env: GMAIL_REFRESH_TOKEN   (default)
    label: agent                               # default: INBOX
    from_addresses: ["*@example.com"]

Polls the Gmail API for unread messages in label and replies into the same Gmail thread. Point label at a label your Gmail filters populate, and the agent owns that label's read-state without touching the rest of your inbox.

The container has no browser, so the refresh token is minted once on your machine:

  1. In Google Cloud Console, create a project, enable the Gmail API, and create an OAuth client of type Desktop app. Note the client id and secret. On the consent screen, publish the app (an app left in "Testing" issues refresh tokens that expire after seven days).

  2. Open this URL in a browser (your client id substituted) and approve access:

    https://accounts.google.com/o/oauth2/v2/auth?client_id=CLIENT_ID&redirect_uri=http://localhost:8085&response_type=code&scope=https://www.googleapis.com/auth/gmail.modify&access_type=offline&prompt=consent
  3. The browser ends up on a localhost:8085 page that fails to load - that's expected, nothing is listening. Copy the code parameter out of the address bar.

  4. Exchange it:

    curl -s https://oauth2.googleapis.com/token \
      -d client_id=CLIENT_ID -d client_secret=CLIENT_SECRET \
      -d code=THE_CODE -d grant_type=authorization_code \
      -d redirect_uri=http://localhost:8085

    The response's refresh_token goes into GMAIL_REFRESH_TOKEN. Google's refresh tokens don't rotate, so this is a one-time step per mailbox.

Outlook

triggers:
  - type: email
    transport: outlook
    client_id: 11111111-2222-3333-4444-555555555555
    # tenant: common                             (default)
    # refresh_token_env: OUTLOOK_REFRESH_TOKEN   (default)
    # client_secret_env: only for confidential clients
    # folder: inbox                              (default)
    from_addresses: ["*@example.com"]

Polls Microsoft Graph for unread messages in folder and replies through Graph's reply endpoint, which threads and quotes for you. The one-time token comes from the device-code flow, which suits a public client with no secret:

  1. In Microsoft Entra, register an app. Pick the account types you need (common covers work and personal), skip the redirect URI, and under Authentication enable Allow public client flows. Add the delegated Graph permissions Mail.ReadWrite, Mail.Send and offline_access.

  2. Start the device flow:

    curl -s https://login.microsoftonline.com/common/oauth2/v2.0/devicecode \
      -d client_id=CLIENT_ID \
      -d scope="https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/Mail.Send offline_access"

    Visit the verification_uri it returns and enter the user_code.

  3. Then collect the token:

    curl -s https://login.microsoftonline.com/common/oauth2/v2.0/token \
      -d client_id=CLIENT_ID \
      -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
      -d device_code=THE_DEVICE_CODE

    The refresh_token goes into OUTLOOK_REFRESH_TOKEN.

One Microsoft-specific caveat: Graph rotates refresh tokens, and the trigger keeps the newest one in memory while it runs. The env var holds the original, so after a long outage (roughly 90 days unused) the token expires and you run the device-code flow again.

An email address is an open channel

The chat triggers inherit a boundary from their platform: only people in your server or workspace can speak to the agent. An email address has no such fence. Anyone on the internet can write to it, which makes inbound mail the framework's widest prompt-injection surface, and every unfiltered message costs tokens besides. So the filtering here is stricter than the chat triggers' optional from_users:

  • from_addresses is required. Exact addresses or *@domain patterns (the domain itself; subdomains don't match). If you genuinely want an open mailbox, write from_addresses: ["*"], and the file that defines the agent's permissions now also says its inbox is open. The check runs on the webhook metadata, before the body is fetched and before the model is called.
  • Every request's signature is verified. Resend signs each webhook (via Svix); the trigger checks the signature with a timing-safe comparison before parsing anything. An unsigned POST to the endpoint is a 401 and no event.
  • Auto-generated mail is dropped. Messages carrying Auto-Submitted or bulk/list Precedence headers are skipped, and so is anything from the agent's own address. An agent that replies to an out-of-office reply to its own reply is a mail loop, and this is the standard defense.

One honest caveat: a From header is an assertion. Resend checks SPF and DKIM on inbound mail, but you should read from_addresses as access control for honest senders plus a cost gate. The defense against a crafted message that gets through is the same as everywhere else in the framework: the permissions block bounds what a fooled agent can actually do.

allow_silence matters more here than on any chat trigger. An agent that files invoices into accounting software should answer nothing at all; instruct it in purpose to reply with exactly __NO_REPLY__ and the trigger sends no email.

Give an agent its own address

The Resend transport shines when the agent owns an address outright: point invoices@example.com at Resend, route it to the agent, done. It also works for personal mail you'd rather keep at arm's length: a Gmail or Fastmail filter that forwards matching messages ("from my accountant", "subject contains invoice") to the agent's address puts exactly the mail you choose in front of it, and the agent's read-state never touches your own mailbox. The pulled transports remove the forwarding step when you're comfortable letting the agent mark mail read in a folder or label of its own. The mail-assistant example is a complete deployment of the forwarding pattern, and the email assistant guide walks through the rest: calendar access, meeting reminders and a spam list the agent maintains itself.

Are you an AI? Visit llms.txt — these docs as plain markdown.

On this page