CarlyEmail docs

Mastra

Turn a Mastra agent into an email agent with MCP tools, a signed webhook route, and one memory thread per email thread.

CarlyEmail is a channel for the Mastra agent you already have. Mastra's MCPClient supplies the email tools, a custom API route receives new mail, and the email's thread_id becomes the Mastra memory thread.

1. Install and create an inbox

npm install @mastra/core @mastra/mcp @mastra/memory @mastra/libsql svix
npx carlyemail signup --human-email you@example.com --username assistant
npx carlyemail verify 123456
export CARLYEMAIL_API_KEY=ce_us_...
export CARLYEMAIL_INBOX=assistant@carlyemail.com
export CARLYEMAIL_WEBHOOK_SECRET=whsec_...

2. Add CarlyEmail's tools

TypeScript

// src/mastra/agents/email-agent.ts
import { Agent } from "@mastra/core/agent";
import { Memory } from "@mastra/memory";
import { MCPClient } from "@mastra/mcp";

const carlyemail = new MCPClient({
  id: "carlyemail",
  servers: {
    carlyemail: {
      url: new URL("https://api.carlyemail.com/mcp"),
      requestInit: {
        headers: {
          Authorization: `Bearer ${process.env.CARLYEMAIL_API_KEY}`,
        },
      },
    },
  },
});

export const emailAgent = new Agent({
  id: "email-agent",
  name: "Email agent",
  model: "openai/gpt-5.4-mini",
  instructions: `You manage ${process.env.CARLYEMAIL_INBOX}.
Read enough context before acting. Reply in the existing thread.
Draft instead of sending when intent, recipient, or authority is ambiguous.`,
  tools: await carlyemail.listTools(),
  memory: new Memory({ options: { lastMessages: 20 } }),
});

Mastra namespaces the discovered tools with the server name. You can filter the returned tool object before giving it to the agent, and should also use an inbox-scoped API key to enforce the allowed operations.

3. Wake the agent from incoming mail

Add a signed route to the Mastra server:

TypeScript

// src/mastra/index.ts
import { Mastra } from "@mastra/core";
import { LibSQLStore } from "@mastra/libsql";
import { registerApiRoute } from "@mastra/core/server";
import { Webhook } from "svix";
import { emailAgent } from "./agents/email-agent";

type EmailEvent = {
  event_type: string;
  event_id: string;
  message?: {
    inbox_id: string;
    message_id: string;
    thread_id: string;
    from: string;
    subject?: string;
    text?: string;
  };
};

export const mastra = new Mastra({
  agents: { emailAgent },
  storage: new LibSQLStore({ id: "mastra", url: "file:./mastra.db" }),
  server: {
    apiRoutes: [
      registerApiRoute("/hooks/carlyemail", {
        method: "POST",
        handler: async (c) => {
          const raw = await c.req.text();
          let event: EmailEvent;

          try {
            event = new Webhook(process.env.CARLYEMAIL_WEBHOOK_SECRET!).verify(
              raw,
              Object.fromEntries(c.req.raw.headers),
            ) as EmailEvent;
          } catch {
            return c.text("Invalid signature", 400);
          }

          if (event.event_type !== "message.received" || !event.message) {
            return c.body(null, 204);
          }

          const message = event.message;
          await emailAgent.generate(
            `Handle this newly received email. Use CarlyEmail's reply tool if a
reply is appropriate.\n\n${JSON.stringify(message)}`,
            {
              memory: {
                resource: message.inbox_id,
                thread: message.thread_id,
              },
            },
          );

          return c.body(null, 204);
        },
      }),
    ],
  },
});

The route verifies the unmodified request body before parsing it. If the agent throws, the route returns a 5xx and CarlyEmail retries the event.

4. Register the route

Deploy the Mastra server, then run:

npx carlyemail webhook https://your-agent.example/hooks/carlyemail \
  --events message.received

Put the printed whsec_... value in CARLYEMAIL_WEBHOOK_SECRET, send the inbox an email from Gmail, and watch the same Mastra memory thread resume when you reply to the agent's response.

Warning

The signature proves that CarlyEmail sent the event; it does not make the email body trustworthy. Deduplicate by event_id, scope the API key, and start with draft-only permissions before enabling autonomous replies.

See also