LOOMAL
Examples

Auto-reply agent

List unread mail, draft replies with Claude, send them, label the originals processed

A Buyer agent that polls its inbox, drafts a reply for each unread message with Claude, sends it on the same thread, and labels the originals so nothing gets answered twice. One file, one loop.

Before you start

  • A project at console.loomal.ai with Mail turned on.
  • Your Loomal API key (loid-...) and an Anthropic API key (sk-ant-...).
  • Send a test message to your project's <prefix>@mailgent.dev address before running — otherwise the inbox is empty and the loop is a no-op.
npm install @loomal/sdk @anthropic-ai/sdk

The agent

extractedText strips quoted history and signatures, so it's the field to hand to the LLM. loomal.mail.reply keeps the message on the same thread automatically — no need to set In-Reply-To headers yourself. Labels are how the agent remembers what it has already handled; the unread label is removed and a custom auto-replied label is added so the next poll skips it.

agent.ts
import { Loomal } from "@loomal/sdk";
import Anthropic from "@anthropic-ai/sdk";

const loomal = new Loomal({ apiKey: process.env.LOOMAL_API_KEY! });
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });

async function processInbox() {
  const { messages } = await loomal.mail.listMessages({
    limit: 20,
    labels: "unread",
  });
  if (messages.length === 0) return;

  for (const m of messages) {
    // Pull the full thread for context — replies should know what came before.
    const thread = await loomal.mail.getThread(m.threadId);
    const transcript = thread.messages
      .map((t) => {
        const who = t.labels.includes("sent") ? "Agent" : t.from[0];
        return `${who}: ${t.extractedText || t.text}`;
      })
      .join("\n\n");

    // Ask Claude to draft a reply to the latest message in the thread.
    const completion = await anthropic.messages.create({
      model: "claude-sonnet-4-6",
      max_tokens: 512,
      system:
        "You are a helpful assistant replying to email on the user's behalf. " +
        "Reply only with the body of the message — no subject, no greeting boilerplate.",
      messages: [
        {
          role: "user",
          content: `Reply to the latest message in this thread:\n\n${transcript}`,
        },
      ],
    });
    const reply = completion.content
      .filter((b): b is Anthropic.TextBlock => b.type === "text")
      .map((b) => b.text)
      .join("\n");

    // Send the reply on the same thread, then label the original processed.
    await loomal.mail.reply(m.messageId, { text: reply });
    await loomal.mail.updateLabels(m.messageId, {
      addLabels: ["read", "auto-replied"],
      removeLabels: ["unread"],
    });

    console.log(`replied to ${m.from[0]} re: ${m.subject}`);
  }
}

// Poll every 30s. In production, use a webhook or scheduled job instead.
while (true) {
  await processInbox().catch((err) => console.error("loop error:", err));
  await new Promise((r) => setTimeout(r, 30_000));
}

Run with LOOMAL_API_KEY=loid-... ANTHROPIC_API_KEY=sk-ant-... npx tsx agent.ts.

What to change next

  • Filter who gets a reply. Add a check on m.from[0] before drafting — e.g., only reply to messages where the sender's domain is on an allowlist.
  • Hand off to a human. Instead of sending immediately, label drafts with needs-review and surface them in your own UI.
  • React to webhooks instead of polling so replies go out within a second of mail arriving.

On this page