Register MultiMail as a function tool in any Assistant. Reads, sends, and replies route through configurable oversight modes before delivery.
The OpenAI Assistants API manages the orchestration layer for you — threads, run lifecycle, tool dispatch, and file context — so you can focus on what the assistant actually does. When that includes email, you need more than a raw SMTP credential attached to a function.
MultiMail exposes its full surface as JSON-schema function tools that register directly in an Assistant definition. The assistant calls check_inbox, send_email, or reply_email; MultiMail handles authentication, rate limiting, oversight gating, and delivery receipts before anything leaves your infrastructure.
Oversight mode is set per mailbox, not per call. An assistant operating in gated_send mode composes messages freely but every outbound send pauses for human approval via the MultiMail dashboard or webhook. The assistant never needs to know whether a given run required human review — the control plane is invisible to the model.
Because Assistants run server-side with persistent threads, inbound email is handled via webhooks that post to your server and optionally resume a Run with a tool output. MultiMail's inbound webhook delivers a structured payload — sender, subject, body, attachments — ready to pass directly into a thread as a tool result.
MultiMail provides pre-built JSON schemas for send_email, check_inbox, read_email, reply_email, and tag_email. Drop them into your Assistant's tools array and submit_tool_outputs works without any translation layer.
The Assistants runtime controls when tools are called, but not what happens after. MultiMail's gated_send mode intercepts every outbound message at the API layer regardless of what the model decided, ensuring approvals can't be bypassed by prompt injection or unexpected tool chaining.
MultiMail webhooks deliver inbound messages as structured JSON. Your server can call submit_tool_outputs to feed the email into an active Run, continuing the thread without polling or background jobs.
Every email sent or received is logged with the thread ID, run ID, and assistant ID that triggered it. Audit trails map back to the exact conversation state that produced each outbound message.
Each assistant gets its own MultiMail API key scoped to specific mailboxes. A customer-support assistant cannot access the mailboxes provisioned for a billing assistant, even if both run under the same OpenAI organization.
from openai import OpenAI
client = OpenAI()
assistant = client.beta.assistants.create(
name="Email Assistant",
model="gpt-4o",
instructions="You help users manage their email. Always confirm before sending.",
tools=[
{
"type": "function",
"function": {
"name": "send_email",
"description": "Send an email from the agent mailbox",
"parameters": {
"type": "object",
"properties": {
"to": {"type": "string", "description": "Recipient email address"},
"subject": {"type": "string"},
"body": {"type": "string"},
"mailbox_id": {"type": "string", "description": "MultiMail mailbox ID"}
},
"required": ["to", "subject", "body", "mailbox_id"]
}
}
},
{
"type": "function",
"function": {
"name": "check_inbox",
"description": "List recent emails in a mailbox",
"parameters": {
"type": "object",
"properties": {
"mailbox_id": {"type": "string"},
"limit": {"type": "integer", "default": 20}
},
"required": ["mailbox_id"]
}
}
}
]
)
print(f"Assistant created: {assistant.id}")Define send_email and check_inbox as function tools when creating or updating an Assistant. The schemas match what MultiMail expects when you forward tool calls.
import time
import requests
from openai import OpenAI
client = OpenAI()
MULTIMAIL_API_KEY = "mm_live_your_key_here"
MULTIMAIL_BASE = "https://api.multimail.dev"
def call_multimail(tool_name: str, args: dict) -> dict:
endpoint_map = {
"send_email": "/v1/send",
"check_inbox": "/v1/inbox",
"read_email": "/v1/email",
"reply_email": "/v1/reply",
}
url = MULTIMAIL_BASE + endpoint_map[tool_name]
resp = requests.post(
url,
json=args,
headers={"Authorization": f"Bearer {MULTIMAIL_API_KEY}"}
)
resp.raise_for_status()
return resp.json()
def run_until_complete(thread_id: str, assistant_id: str):
run = client.beta.threads.runs.create(
thread_id=thread_id,
assistant_id=assistant_id
)
while run.status in ("queued", "in_progress", "requires_action"):
time.sleep(1)
run = client.beta.threads.runs.retrieve(thread_id=thread_id, run_id=run.id)
if run.status == "requires_action":
tool_outputs = []
for call in run.required_action.submit_tool_outputs.tool_calls:
import json
args = json.loads(call.function.arguments)
result = call_multimail(call.function.name, args)
tool_outputs.append({
"tool_call_id": call.id,
"output": json.dumps(result)
})
run = client.beta.threads.runs.submit_tool_outputs(
thread_id=thread_id,
run_id=run.id,
tool_outputs=tool_outputs
)
return runPoll for required_action on a Run, dispatch each tool call to the MultiMail API, then submit all outputs to continue the thread.
from flask import Flask, request, jsonify
from openai import OpenAI
import json
app = Flask(__name__)
client = OpenAI()
"cm"># Map mailbox -> active (thread_id, run_id) when awaiting a reply
active_runs: dict[str, tuple[str, str]] = {}
@app.route("/webhooks/multimail/inbound", methods=["POST"])
def handle_inbound():
payload = request.json
mailbox_id = payload["mailbox_id"]
email_data = {
"from": payload["from"],
"subject": payload["subject"],
"body": payload["body"],
"email_id": payload["id"]
}
if mailbox_id in active_runs:
thread_id, run_id = active_runs[mailbox_id]
"cm"># Feed the inbound email back into the waiting run
client.beta.threads.runs.submit_tool_outputs(
thread_id=thread_id,
run_id=run_id,
tool_outputs=[{
"tool_call_id": payload.get("tool_call_id", "inbound"),
"output": json.dumps(email_data)
}]
)
else:
"cm"># Start a new thread for this inbound message
thread = client.beta.threads.create(
messages=[{
"role": "user",
"content": f"New email received: {json.dumps(email_data)}"
}]
)
"cm"># Trigger a run with your assistant
client.beta.threads.runs.create(
thread_id=thread.id,
assistant_id="asst_your_assistant_id"
)
return jsonify({"status": "ok"})MultiMail's inbound webhook posts structured email data to your server. Forward it into the thread as a tool output to continue an active Run, or start a new thread if none is active.
import time
import requests
from openai import OpenAI
client = OpenAI()
MULTIMAIL_API_KEY = "mm_live_your_key_here"
def send_with_approval_check(thread_id: str, run_id: str, tool_call_id: str, send_args: dict):
"""Forward a send_email tool call and wait for human approval."""
resp = requests.post(
"https://api.multimail.dev/v1/send",
json=send_args,
headers={"Authorization": f"Bearer {MULTIMAIL_API_KEY}"}
).json()
if resp.get("status") == "pending_approval":
message_id = resp["id"]
print(f"Message {message_id} awaiting approval in MultiMail dashboard")
"cm"># Poll until approved or rejected (max 10 minutes)
for _ in range(120):
time.sleep(5)
status_resp = requests.get(
f"https://api.multimail.dev/v1/messages/{message_id}",
headers={"Authorization": f"Bearer {MULTIMAIL_API_KEY}"}
).json()
if status_resp["status"] in ("sent", "rejected"):
result = {"status": status_resp["status"], "id": message_id}
break
else:
result = {"status": "timeout", "id": message_id}
else:
result = resp
"cm"># Return the outcome to the Assistant
client.beta.threads.runs.submit_tool_outputs(
thread_id=thread_id,
run_id=run_id,
tool_outputs=[{"tool_call_id": tool_call_id, "output": str(result)}]
)When a mailbox uses gated_send mode, MultiMail holds the message and returns a pending status. Poll list_pending to check approval state, then submit the result to the Assistant as a tool output.
Sign up at multimail.dev and provision a mailbox for your assistant. Set the oversight mode to gated_send to require approval on all outbound messages during development.
curl -X POST https://api.multimail.dev/v1/mailboxes \
-H "Authorization: Bearer $MULTIMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d &"cm">#039;{"address": "[email protected]", "oversight_mode": "gated_send"}'Add send_email and check_inbox to your Assistant's tools list. Use the JSON schemas that match MultiMail's request body format so tool arguments pass through without transformation.
assistant = client.beta.assistants.create(
name="Email Agent",
model="gpt-4o",
tools=[send_email_tool_schema, check_inbox_tool_schema]
)Poll run status and intercept requires_action states. For each tool call, forward arguments to the corresponding MultiMail endpoint and collect the JSON response.
run = client.beta.threads.runs.retrieve(thread_id=tid, run_id=run.id)
if run.status == "requires_action":
outputs = dispatch_tool_calls(run.required_action.submit_tool_outputs.tool_calls)
client.beta.threads.runs.submit_tool_outputs(thread_id=tid, run_id=run.id, tool_outputs=outputs)In the MultiMail dashboard, set an inbound webhook URL pointing to your server. MultiMail will POST structured email payloads on new delivery, which you can feed into active threads or use to trigger new runs.
curl -X POST https://api.multimail.dev/v1/mailboxes/mbx_your_id/webhooks \
-H "Authorization: Bearer $MULTIMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d &"cm">#039;{"url": "https://yourapp.com/webhooks/multimail/inbound", "events": ["email.received"]}'With gated_send active, outbound messages appear in the MultiMail approval queue. Review and approve from the dashboard or via the API. Once approved, MultiMail delivers the message and your run continues.
Email infrastructure built for AI agents. Verifiable identity, graduated oversight, and a 38-tool MCP server. Formally verified in Lean 4.