CarlyEmail docs

OpenAI Agents SDK

Give an OpenAI Agents SDK agent email tools and invoke it from signed incoming-mail events.

The OpenAI Agents SDK turns ordinary Python functions into model tools. CarlyEmail's Python SDK handles the email API, while a signed webhook starts the agent on each new message.

1. Install and create an inbox

pip install "carlyemail-toolkit[openai]" fastapi uvicorn
npx carlyemail signup --human-email you@example.com --username assistant
npx carlyemail verify 123456
export OPENAI_API_KEY=sk-...
export CARLYEMAIL_API_KEY=ce_us_...
export CARLYEMAIL_INBOX=assistant@carlyemail.com
export CARLYEMAIL_WEBHOOK_SECRET=whsec_...

2. Give the agent email tools

carlyemail-toolkit turns the hosted server's tools into FunctionTools. Name the ones this agent needs:

Python

from agents import Agent
from carlyemail_toolkit.openai import CarlyEmailToolkit

toolkit = CarlyEmailToolkit()  # reads CARLYEMAIL_API_KEY
list_messages, get_thread, reply = toolkit.get_tools(
    ["list_messages", "get_thread", "reply_to_message"]
)

inbox_agent = Agent(
    name="Inbox",
    instructions=(
        "Read the message and thread before acting. Never invent facts. "
        "Draft rather than send when intent or authority is ambiguous."
    ),
    tools=[list_messages, get_thread, reply],
)

The key is scoped to one inbox, so no tool needs inbox_id. Only expose the operations the agent needs, and pair that model-visible list with the inbox-scoped API key, so a missing permission still returns 403 if a tool or guardrail is later misconfigured. See Toolkits for the full list and for writing the tools by hand instead.

3. Invoke it when mail arrives

Python

import json

from agents import Runner
from carlyemail.inbound import create_email_router
from fastapi import FastAPI

app = FastAPI()


async def on_email(email):
    await Runner.run(
        inbox_agent,
        "Handle this newly received email. Reply if appropriate.\n\n"
        + json.dumps(email.message),
    )


app.include_router(
    create_email_router(
        on_email,
        path="/hooks/carlyemail",
        allow_from=["you@example.com"],
    )
)

create_email_router verifies the signature over the raw bytes, admits message.received and nothing else, drops mail the inbox sent itself, checks the sender, ignores redeliveries, and answers the request before the agent runs — so a run that outlasts the delivery timeout is not retried into a second reply. See receiving mail for the options and for doing it without FastAPI.

uvicorn app:app --host 0.0.0.0 --port 8000
npx carlyemail webhook https://your-agent.example/hooks/carlyemail \
  --events message.received

The event's thread_id is the stable key to pass to your own session store when the agent needs private memory between messages. CarlyEmail preserves the real email headers when reply is called.

Using more than one agent

The SDK offers two ways to involve a second agent, and email makes the difference between them sharp. One email gets one reply, so the question is always: who writes it?

Handoffs — one specialist takes over

A handoff transfers control permanently. The specialist becomes the responder and the first agent never runs again. That is right when the mail belongs to exactly one specialist and routing is the whole job.

Python

billing = Agent(name="Billing", instructions="Answer billing questions.", tools=[reply])
support = Agent(name="Support", instructions="Answer product questions.", tools=[reply])

triage = Agent(
    name="Triage",
    instructions="Read new mail and hand off to the right specialist.",
    tools=[list_messages, get_thread],
    handoffs=[billing, support],
)

Note that each specialist holds reply and triage does not. Whoever ends up in control is the one who needs it.

Agents as tools — the lead keeps the pen

as_tool() calls a specialist, gets its work back, and carries on. Use this whenever one reply draws on more than one specialist — "we're being acquired, what changes for us?" needs billing and support in a single email, and a handoff can only pick one.

Python

lead = Agent(
    name="Lead",
    instructions=(
        "Read the thread, consult whichever specialists apply, then write one "
        "reply covering everything the sender asked."
    ),
    tools=[
        list_messages,
        get_thread,
        reply,
        billing.as_tool(
            tool_name="billing",
            tool_description="Ask the billing specialist about invoices, plans and refunds.",
        ),
        support.as_tool(
            tool_name="support",
            tool_description="Ask the support specialist how the product behaves.",
        ),
    ],
)

Here reply belongs to the lead, because the lead is what composes. A specialist reached through as_tool() returns text to the lead rather than mailing anyone, which is usually what you want: one sender, one answer, one place where the outbound decision is made.

Note

The failure to watch for is a lead with handoffs when you wanted tools. The first specialist answers, the reply goes out covering a third of the question, and the lead never gets the chance to notice — nothing errors, the email is just incomplete.

Warning

Email content is untrusted model input, and a verified delivery says CarlyEmail sent it — not that the sender's instructions are safe to follow. The router handles the transport; the boundary that holds is an inbox-scoped API key. Start with draft-only permissions when a person should approve outbound mail, so message_send returns 403 whatever the model decides.

The Agents API

OpenAI's Agents API runs the agent for you, in a sandbox OpenAI hosts. Nothing on your side stays running while it works. An email starts a session, and the last thing the agent writes becomes the reply.

Python

from openai import OpenAI
from carlyemail import CarlyEmail
from carlyemail.inbound import create_email_router
from fastapi import FastAPI

openai = OpenAI()
carly = CarlyEmail()
app = FastAPI()


def on_email(email):
    texts = []
    with openai.beta.agents.sessions.create(
        agent={"model": "gpt-6-astra", "instructions": "Do the task. Reply in plain text."},
        environment={"type": "openai_hosted"},
        input=email.text,
        stream=True,
    ) as events:
        for event in events:
            if event.type == "agent.session.turn.output_text.done":
                texts.append(event.text)
            elif event.type == "agent.session.idle" and texts:
                break
    carly.messages.reply(email.inbox_id, email.message_id, {"text": texts[-1]})


app.include_router(create_email_router(on_email, path="/hooks/carlyemail"))

The OpenAI sandbox example goes further: it keeps one session per thread, so a reply continues the same session, and it attaches whatever the agent saved to /workspace/outputs.

See also