Eve
Give an Eve agent a mailbox that answers email and WhatsApp through two tools, with each reply held for a person to approve.
This guide gives an Eve agent its own mailbox. Eve agents run as a server, so the setup is two processes: the Eve server holds the agent, and a small relay follows the mailbox's event stream and hands each incoming message to it. The agent runs once per message and answers email and WhatsApp through two tools. Each reply is held until a person approves it.
What you need
- A mailbox, as in Give an agent an inbox.
- A mailbox-scoped key for the agent with a rule that holds its sends for a person. See Agents and keys and Approve sends.
- Node.js 24 or newer.
npm install eve ai samva zodTake each message into the agent
An Eve channel connects a source of messages to the agent. This one has a single route. The relay posts each received message to it, and the route starts one agent run, named and keyed by the event id so a redelivered event reaches the same run. The ids the tools need go into the channel's state, outside the prompt.
import { defineChannel, POST } from "eve/channels";
export default defineChannel<MessageRef>({
state: { eventId: "", mailboxId: "", messageId: "", channel: "email" },
metadata: (state) => ({ ...state }),
routes: [
POST("/samva/messages", async (request, { from }) => {
const token = process.env.EVE_RELAY_TOKEN;
if (token === undefined || token === "") {
throw new Error("Missing required env var EVE_RELAY_TOKEN");
}
return await acceptMessage(request, token, (address, message, options) =>
from(address).send(message, options),
);
}),
],
});The customer's message is untrusted data. The route wraps it in <customer_message> tags, and
agent/instructions.md tells the model to answer it and never to follow instructions inside it.
The two tools
Each tool is a file under agent/tools/, and the file name is the tool name. reply_to_customer
submits a reply and leave_for_person sends nothing. Both read the message and event id from
ctx.channel.metadata, so the model supplies only the reply text or a reason. endsTurn: true
ends the run once a tool succeeds. The event id keys the reply, so a replayed event cannot send
twice. claimEvent takes a claim on the event id before any request starts, so the first tool call
wins and a model that calls both tools in one step cannot reply and also leave the message for a
person.
import { defineTool } from "eve/tools";
import { SamvaApiError } from "samva";
import { z } from "zod";
import { claimEvent, holdForReplyRetry, releaseEvent } from "../lib/claim";
import { parseMessageRef } from "../lib/message";
import { samvaFromEnv, submitReply } from "../lib/reply";
// The ids come from the channel's state, so the model supplies only the text: it cannot choose
// the recipient, channel, or thread.
export default defineTool({
description:
"Submit a reply to the customer's message. It is held for a person's approval before it is sent.",
inputSchema: z.object({ text: z.string().trim().min(1).describe("The plain-text reply.") }),
endsTurn: true,
async execute({ text }, ctx) {
const ref = parseMessageRef(ctx.channel.metadata);
if (!claimEvent(ref.eventId, "reply")) return { state: "alreadyActed" };
try {
return await submitReply(samvaFromEnv(), ref, text);
} catch (error) {
// A 4xx answer means Samva refused the reply and recorded nothing, so either tool may act
// next. Anything else (a lost response, a server error) may have recorded it: keep the
// event for a reply retry, which the reply key makes safe.
const refused =
error instanceof SamvaApiError &&
error.status !== undefined &&
error.status >= 400 &&
error.status < 500;
if (refused) releaseEvent(ref.eventId);
else holdForReplyRetry(ref.eventId);
throw error;
}
},
});import { MailboxSendConflictError, WhatsAppServiceWindowClosedError } from "samva";
const reply =
ref.channel === "whatsapp"
? { id: ref.mailboxId, messageId: ref.messageId, channel: "whatsapp" as const, text }
: { id: ref.mailboxId, messageId: ref.messageId, text };
try {
const receipt = await samva.mailboxes.reply(reply, { idempotencyKey: `reply:${ref.eventId}` });
return { state: receipt.state, actionId: receipt.actionId };
} catch (error) {
if (error instanceof MailboxSendConflictError) {
return { state: "alreadyAnswered", actionId: error.receipt.actionId };
}
if (error instanceof WhatsAppServiceWindowClosedError) return { state: "windowClosed" };
throw error;
}A replayed event is refused with MailboxSendConflictError, which carries the first reply's
receipt, so the agent has nothing left to do and the tool reports alreadyAnswered. Any other
conflict, such as a paused mailbox, is a failure and the event is retried. A closed WhatsApp service
window is reported as windowClosed. Any other error is thrown to Eve, which returns it to the
model and never retries a tool itself.
Follow the mailbox with the relay
Follow the mailbox with samva.mailboxes.events and post each mailbox.message.received event to
the Eve route. See Mailbox events for the event stream. The relay saves
its cursor only after Eve accepts an event, so a crash replays it and the event id keeps the
replay from running twice.
for await (const event of samva.mailboxes.events({ cursor, mailboxIds: [options.mailboxId] })) {
await handleEvent(event, options.forward, log);
await options.store.save(event.cursor);
cursor = event.cursor;
}Start the two processes with eve dev for the agent and the example's relay script for the
relay. Both need SAMVA_API_KEY and a shared EVE_RELAY_TOKEN, and the agent also needs
ANTHROPIC_API_KEY. The agent uses anthropic("claude-opus-5-5") from eve/models/anthropic.
Answer WhatsApp from the same mailbox
A WhatsApp message arrives as the same mailbox.message.received event, and the reply tool answers
it with channel: "whatsapp". Add your connected number to the mailbox as described in
Answer WhatsApp in a mailbox.
Next steps
The Eve cookbook walks through the pieces in more depth. The runnable example includes both processes and the tests.