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.