Skip to content
Browse the docs

Frameworks and agents · Published 2026-10-21 · Updated 2026-10-21 · AgentiSend

Send email from the OpenAI Agents SDK

AgentiSend mail from the OpenAI Agents SDK goes through one function_tool invoked with on_invoke_tool, and a refusal comes back as code and fix.

To send email from the OpenAI Agents SDK on AgentiSend, register one function_tool that posts to POST /emails and is invoked through on_invoke_tool, returning a refusal as code and fix.

Install

pip install openai-agents

The agentisend package is standard library only, so pip install agentisend adds no other dependency.

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

"""OpenAI Agents SDK — one function tool that posts to POST /emails.

The loop invokes the tool through the SDK's own `on_invoke_tool`, which is
the path a run uses after the model emits a tool call. The first call sends.
The second is a different message, so a key whose budget is one comes back
as agent_budget_exceeded. No model is called.

Install both packages with `pip install agentisend openai-agents`. The suite
runs this file against the SDK checkout; a fresh project uses the released package.

AGENTISEND_API_KEY is the agent's key, with sending_access and a budget.
MAIL_FROM is an address on a domain you have verified.
AGENTISEND_BASE_URL is optional.
"""

from __future__ import annotations

import asyncio
import json
import os

from agents import function_tool
from agents.tool_context import ToolContext

from agentisend import AgentiSend, AgentiSendError, idempotency_key

agentisend = AgentiSend()
TO = "openai-agents@example.com"
UPDATE = {
    "to": TO,
    "subject": "Ticket 4182 — we are looking into it",
    "text": "Someone from support will reply within the hour.",
    "purpose": "ticket-4182-update",
}
CLOSED = {
    "to": TO,
    "subject": "Ticket 4182 — closed",
    "text": "The ticket is closed.",
    "purpose": "ticket-4182-closed",
}


@function_tool
def send_email(to: str, subject: str, text: str, purpose: str) -> str:
    """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.
    """
    try:
        sent = agentisend.send_email(
            {"from": os.environ["MAIL_FROM"], "to": to, "subject": subject, "text": text},
            idempotency=idempotency_key(purpose, to),
        )
    except AgentiSendError as err:
        return json.dumps({"sent": False, "code": err.code, "fix": err.fix})
    return json.dumps({"sent": True, "id": sent["id"]})


async def _once(arguments: dict) -> dict:
    ctx = ToolContext(
        context=None,
        tool_name="send_email",
        tool_call_id="call_" + arguments["purpose"],
        tool_arguments=json.dumps(arguments),
    )
    raw = await send_email.on_invoke_tool(ctx, json.dumps(arguments))
    parsed = json.loads(raw)
    if not isinstance(parsed, dict):
        raise RuntimeError(f"tool returned {raw!r}")
    return parsed


def run() -> None:
    """Two tool calls through the SDK, then stop on the refusal."""

    async def loop() -> None:
        for call in (UPDATE, CLOSED):
            result = await _once(call)
            print(json.dumps(result))
            if result.get("sent") is False:
                return

    asyncio.run(loop())


if __name__ == "__main__":
    run()
examples/openai-agents-sdk/tool.py, run by the test suite against a local AgentiSend server

function_tool publishes send_email. The file does not start a hosted run. It builds a ToolContext and calls on_invoke_tool with the JSON arguments, which is the path a run uses after the model emits a tool call. The function returns a JSON string. When that string is a refusal whose fix says a person decides, stop. The second call in the file is a different message, so a budget of one produces:

{
  "error": {
    "code": "agent_budget_exceeded",
    "message": "This key has used 1 of its 1 recipients for the current monthly period. Each To, CC and BCC recipient counts as one email.",
    "fix": "Wait for the period to reset — get_agent_budget and GET /limits/keys/:id both say when. Raising a budget is a person’s decision, made in the console; a key cannot raise its own.",
    "docs_url": "https://agentisend.com/what-does-agent-budget-exceeded-mean#agent_budget_exceeded",
    "retryable": false
  }
}

idempotency_key(purpose, to) is what keeps a retry from sending twice. The Python calls that mint the key are in Send email from Python. A full-access key lists arrivals with GET /emails/receiving. This key replies with POST /emails/receiving/{id}/reply. Both are spelled out in Receiving and replying.

Next