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.devaddress before running — otherwise the inbox is empty and the loop is a no-op.
npm install @loomal/sdk @anthropic-ai/sdkThe 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.
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-reviewand surface them in your own UI. - React to webhooks instead of polling so replies go out within a second of mail arriving.