Skip to Content

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

npm install eve ai samva zod

Take 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.

agent/channels/samva.ts
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.

agent/tools/reply_to_customer.ts
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;
    }
  },
});
agent/lib/reply.ts
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.

src/relay.ts
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.

Related documentation

On this page