Mastra is a TypeScript framework for agents and workflows: tools are typed with a schema, agents take a model string and a set of tools, and workflows are steps that can suspend and resume from storage. That last part is what makes it a good fit for email, because the slow part of an email task is waiting for a message you do not control.
This post gives a Mastra agent its own inbox in about 30 lines, adds a send tool that needs a human's approval, and then moves the wait out of the agent loop into a workflow step that suspends until mail arrives. The code targets @mastra/core 1.x, where a tool's execute takes the validated input as its first argument. Older examples that read context off a single argument are from the pre-1.0 API.
The agent and its receive tools
// src/mastra/tools/inbox.ts
import { createTool } from "@mastra/core/tools";
import { Lumbox } from "lumbox";
import { z } from "zod";
const lumbox = new Lumbox({ maxRetries: 0 }); // reads LUMBOX_API_KEY
export const createInbox = createTool({
id: "create-inbox",
description: "Create a fresh email address for one task.",
inputSchema: z.object({}),
execute: async () => {
const inbox = await lumbox.createInbox();
return { inboxId: inbox.id, address: inbox.address };
},
});
export const waitForOtp = createTool({
id: "wait-for-otp",
description: "Wait up to 90 seconds for a verification code. Returns code: null if none arrived.",
inputSchema: z.object({ inboxId: z.string(), from: z.string().optional() }),
execute: async ({ inboxId, from }) => {
const inbox = await lumbox.getInbox(inboxId);
try {
return { code: (await inbox.waitForOtp({ from, timeout: 90 })).code };
} catch (err) {
if (err instanceof Lumbox.ApiError && err.status === 408) return { code: null };
throw err;
}
},
});
// src/mastra/agents/signup-agent.ts
import { Agent } from "@mastra/core/agent";
import { createInbox, waitForOtp } from "../tools/inbox.ts";
import { sendEmail } from "../tools/send.ts";
export const signupAgent = new Agent({
id: "signup-agent",
name: "Signup Agent",
instructions:
"Create one inbox per task and use its address for the signup. " +
"After the form is submitted, wait for the code. Only send email when asked.",
model: "anthropic/claude-sonnet-4-6",
tools: { createInbox, waitForOtp, sendEmail },
});
The model string uses Mastra's model router, which reads ANTHROPIC_API_KEY from the environment, so there is no provider import. The lumbox package handles the receive side: createInbox() returns an address on trylumbox.com, and waitForOtp() is a long-poll that returns { code, all_codes, from, subject, expires, email_id } or a 408 when the timeout passes.
The client is built with maxRetries: 0 on purpose. The SDK retries 408 responses by default, which is right for ordinary requests but turns a 90-second wait into several minutes of silence inside an agent step. With retries off, the tool returns code: null after 90 seconds and the model can decide whether to request a new code.
Sending, behind approval
// src/mastra/tools/send.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const sendEmail = createTool({
id: "send-email",
description: "Send a plain-text email from an inbox.",
requireApproval: true,
inputSchema: z.object({
inboxId: z.string(),
to: z.string(),
subject: z.string(),
text: z.string(),
}),
execute: async ({ inboxId, ...msg }) => {
const res = await fetch(`https://api.lumbox.co/v1/inboxes/${inboxId}/send`, {
method: "POST",
headers: { "X-API-Key": process.env.LUMBOX_API_KEY!, "Content-Type": "application/json" },
body: JSON.stringify(msg),
});
const body = await res.json();
return res.ok ? { emailId: body.email_id } : { error: body.error };
},
});
The send goes through fetch, so the tool returns the API's own response (email_id, thread_id, message_id) unchanged.
requireApproval: true is Mastra's pre-execution approval. When the model calls send-email, execute does not run. The agent's stream emits a tool-call-approval chunk with the tool name and the arguments the model chose, and the run waits until you call agent.approveToolCall({ runId }) or agent.declineToolCall({ runId }). Approval state is kept as a snapshot, so the Mastra instance needs a storage provider configured, or you get a "snapshot not found" error when you try to approve.
Moving the wait into a workflow
An agent tool that blocks for 90 seconds is fine for a demo. It is less fine when the email might take ten minutes, when the process restarts in between, or when you are paying for a model session to sit idle. A Mastra workflow step can instead check once, and if nothing has arrived, suspend(). The run's state is written to storage and nothing is running until something calls resume().
// src/mastra/workflows/signup.ts
import { createStep, createWorkflow } from "@mastra/core/workflows";
import { z } from "zod";
async function checkForCode(inboxId: string, since: string) {
const qs = new URLSearchParams({ since, timeout: "5" });
const res = await fetch(`https://api.lumbox.co/v1/inboxes/${inboxId}/otp?${qs}`, {
headers: { "X-API-Key": process.env.LUMBOX_API_KEY! },
});
if (res.status === 408) return null;
if (!res.ok) throw new Error(`Lumbox ${res.status}`);
return (await res.json()).code as string;
}
const waitForCode = createStep({
id: "wait-for-code",
inputSchema: z.object({ inboxId: z.string(), submittedAt: z.string() }),
resumeSchema: z.object({}),
outputSchema: z.object({ code: z.string() }),
execute: async ({ inputData, suspend }) => {
const code = await checkForCode(inputData.inboxId, inputData.submittedAt);
if (!code) return await suspend({});
return { code };
},
});
export const signupWorkflow = createWorkflow({
id: "signup",
inputSchema: z.object({ inboxId: z.string(), address: z.string() }),
outputSchema: z.object({ done: z.boolean() }),
})
.then(submitSignup) // your step: fills the form, returns { inboxId, submittedAt }
.then(waitForCode)
.then(enterCode) // your step: submits the code
.commit();
Two details make the resume safe. The step reads the code from Lumbox every time it runs rather than trusting anything passed in resumeData, so a resume is only a signal to look again. And the check passes since as the time the form was submitted. Without it, /otp looks back five minutes and could return a code from an earlier attempt in the same inbox.
Resuming from a Lumbox webhook
Create the inbox before starting the run and use its ID as the run ID (createRun({ runId: inbox.id })). Then a webhook for email.received can find the run directly:
import { createHmac, timingSafeEqual } from "node:crypto";
import { mastra } from "./mastra";
export async function POST(req: Request) {
const raw = await req.text();
const sig = Buffer.from(req.headers.get("x-lumbox-signature") ?? "");
const expected = Buffer.from(
createHmac("sha256", process.env.LUMBOX_WEBHOOK_SECRET!).update(raw).digest("hex"),
);
if (sig.length !== expected.length || !timingSafeEqual(sig, expected)) {
return new Response("bad signature", { status: 401 });
}
const { event, data } = JSON.parse(raw);
if (event === "email.received") {
const run = await mastra.getWorkflow("signupWorkflow").createRun({ runId: data.inbox_id });
await run.resume({ step: "wait-for-code", resumeData: {} }).catch(() => {});
}
return new Response("ok");
}
Lumbox signs each delivery with HMAC-SHA256 over the raw JSON body, hex-encoded in X-Lumbox-Signature, keyed with the full whsec_... secret returned when you create the webhook. Webhooks are per org, not per inbox, so every inbox's mail reaches this handler; a resume for an inbox with no suspended run just fails and is ignored.
Webhook deliveries for received mail are not retried today. If your endpoint is down when the email arrives, the run stays suspended. Pair the webhook with a timer or cron that resumes suspended runs every few minutes; because the step re-checks Lumbox with since, an extra resume costs one short request. And delete the inbox in the last step: inboxes do not expire, and the Free plan holds three at a time (pricing).
Which one to use
The agent tool is the right shape when the email usually arrives within a minute and a person is watching: the model sees code: null, clicks "resend" through its browser tool, and waits once more. It keeps everything in one conversation, and it costs model time while it waits.
The workflow is the right shape when the delay is unpredictable or the task runs unattended. The model does the parts that need judgment (filling the form, reading an error page), and the wait itself is a suspended run that uses no compute. The tradeoff is more moving parts: a storage provider, a webhook endpoint with its secret, and a timer for missed deliveries.
Either way, the code comes from the same /otp endpoint, and in both cases the parser only finds numeric codes of 4 to 8 digits in emails it categorised as verification mail. A service that sends only a confirmation link needs GET /v1/inboxes/:id/wait and the parsed.verification_links field instead.
The webhooks docs cover creating the endpoint and its secret, the Node.js guide covers the REST calls used here, and the Vercel AI SDK version shows the same tools with a different approval model.