Browse the docs

Frameworks and agents · Updated 2026-09-27

Send email from a LangChain.js agent

A send_email tool built with tool() from @langchain/core/tools that sends through the agent's own budgeted key and hands a refusal back to the model as code and fix.

To send email from a LangChain.js agent, give it one tool() from @langchain/core/tools whose function sends through the agent's own key and returns either the message id or the refusal's code and fix. The file below is that tool. It is run against a real API on every build of this site's repository, invoked directly with the arguments a model would produce: one send, the same call again replaying it, a third send refused once the key's budget is spent, and a call with no recipient refused by the schema before the API is reached.

Install

pnpm add @langchain/core zod agentisend

Environment

VariableRequiredWhat it is
AGENTISEND_API_KEYyesThe agent's key, with sending_access and a budget on it. Not your own.
MAIL_FROMyesThe From address, on a domain you have verified.
AGENTISEND_BASE_URLnoDefaults to https://api.agentisend.com.

The tool

/**
 * LangChain.js — a `send_email` tool for an agent that holds its own key.
 *
 * The key in `AGENTISEND_API_KEY` here is the agent's, not yours. It was
 * minted with `POST /api-keys` as `sending_access`, given a ceiling with
 * `PATCH /limits/keys/:id`, and `POST /limits/kill-all` stops it along with
 * every other key. Those three calls run in examples/ai-agent-with-budget;
 * this file is the other half, the tool the model calls once the key exists.
 *
 * Two rules make it safe to hand to a model. The idempotency key is derived
 * from the purpose and the recipient, never from the moment, so a model that
 * retries a timed-out call replays the first send instead of mailing someone
 * twice. And a refusal is returned, not thrown: the model reads `code` and
 * `fix` and acts on them — for `agent_budget_exceeded` and `approval_required`
 * that means stopping and saying so, because the fix names a person.
 */
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { AgentiSend, AgentiSendError } from 'agentisend';

/** The agent's own key: `sending_access`, with a budget on it. */
const agentisend = new AgentiSend();

function mailFrom(): string {
  const from = process.env.MAIL_FROM;
  if (!from) throw new Error('Set MAIL_FROM to an address on a domain you have verified.');
  return from;
}

/** What the model gets back: a message id, or a refusal it can act on. */
export type SendEmailResult =
  | { sent: true; id: string }
  | { sent: false; code: string; fix: string };

export const sendEmail = tool(
  async ({ to, subject, text, purpose }): Promise<SendEmailResult> => {
    try {
      const { id } = await agentisend.emails.send(
        { from: mailFrom(), to, subject, text },
        // Derived from the thing being done, not from the moment it was asked
        // for: a retry after a timeout replays the first send.
        { idempotencyKey: `${purpose}/${to}` },
      );
      return { sent: true, id };
    } catch (err) {
      if (err instanceof AgentiSendError) {
        // The API checked the address, the suppression list, the budget and
        // the loop guard, and the refusal names its fix. The model can read
        // that; it cannot read an exception.
        return { sent: false, code: err.code, fix: err.fix };
      }
      throw err;
    }
  },
  {
    name: 'send_email',
    description:
      'Send one email to a person who asked for it. Returns the message id, or a refusal ' +
      'with a code and a fix. When the fix says a person decides, stop and report it: ' +
      'calling again will not change the answer.',
    schema: z.object({
      to: z.string().describe('The recipient, one address.'),
      subject: z.string().min(1).max(200),
      text: z.string().min(1).describe('The plain-text body.'),
      purpose: z
        .string()
        .regex(/^[a-z0-9][a-z0-9_-]{0,63}$/)
        .describe(
          'What this message is, for example "ticket-4182-update". The same purpose to the ' +
            'same recipient sends once, however many times it is called.',
        ),
    }),
  },
);
examples/langchain/send-email-tool.ts, executed against the live API on every build

Wire it to a model

import { createAgent } from 'langchain';
import { sendEmail } from './send-email-tool';

const agent = createAgent({ model, tools: [sendEmail] });
await agent.invoke({
  messages: [{ role: 'user', content: 'Tell customer@example.com that ticket 4182 is being looked into.' }],
});

LangChain checks the model's arguments against schema before the function runs, and serialises what the function returns into the tool message the model reads next. A refusal arrives as { sent: false, code, fix }, and the fix is written for that reader: it says what to do and which call does it, or that a person decides and a retry will not change the answer.

The key the agent holds

Mint the agent a key of its own and put a ceiling on it before the tool ever sees it. The three calls are executed in An AI agent with a budget and a loop guard; what each one does:

  • POST /api-keys with sending_access mints a key that can send and read the mail it sent itself, and nothing else. It cannot touch your domains, read mail that arrived, or mint further keys.
  • PATCH /limits/keys/{id} sets budget_per_period and period. When the agent reaches the ceiling the tool returns agent_budget_exceeded; its fix says to wait for the period to reset (GET /limits/keys/{id} says when) and that raising the budget is a person's decision, made in the console. The key cannot raise its own.
  • POST /limits/kill-all stops every key on the account at once, from anywhere; POST /limits/keys/{id}/kill stops this one. A killed key's next send is refused, and the tool returns that refusal like any other.
  • The loop guard runs on every send: a message that would be the 4th near-identical one to the same recipient inside 60 minutes is held for a person and comes back as approval_required, with the held action's id.

The idempotency key

Every send carries Idempotency-Key: <purpose>/<recipient>, derived from what the message is and never from when it was asked for. A model that retries a timed-out tool call gets the first send's id back instead of mailing the same person twice, and two calls that mean the same thing send once. Choose the purpose so that two different messages never share one: ticket-4182-update and ticket-4182-closed are two sends; ticket-4182 for both would replay the first.

Next