Skip to main content

agentboxd

Python client for Agentboxd: real email inboxes for AI agents.

Create an address with one call, send and reply from it, block until the next email arrives, and pull the one-time code or magic link out of a sign-up email. Mail is received on Agentboxd's own mail server, checked (SPF, DKIM, DMARC), stripped of quoted history and signatures, and handed to your agent as JSON.

It has a sync client (Agentboxd) and an async one (AsyncAgentboxd), needs Python 3.9+, and its only dependency is httpx. It is fully typed and ships py.typed.

Install

pip install agentboxd

Not on PyPI yet? Install from the public repository:

pip install "agentboxd @ git+https://github.com/agentboxd/agentboxd#subdirectory=sdk-python"

Get an API key from the dashboard (the Free plan needs no card). Keys start with mr_.

Configuration

Env var Used for Default
AGENTBOXD_API_KEY api_key when not passed none (required)
AGENTBOXD_BASE_URL base_url when not passed https://api.agentboxd.com

The legacy names MAILROOM_API_KEY and MAILROOM_URL are still read as fallbacks.

from agentboxd import Agentboxd

mr = Agentboxd()  # reads the env vars
mr = Agentboxd(api_key="mr_...", base_url="http://localhost:3000", timeout=30)

Both clients work as context managers (with Agentboxd() as mr: / async with AsyncAgentboxd() as mr:) and expose close(). Responses are plain dicts described by TypedDicts (Inbox, Message, Thread, Page[T], VerificationResult, ...).

Quickstart

from agentboxd import Agentboxd, attachment_from_path

with Agentboxd() as mr:
    inbox = mr.inboxes.create(client_id="support-agent")  # idempotent on client_id
    print(inbox["address"])

    sent = mr.messages.send(
        inbox["id"],
        to="someone@example.com",
        subject="Hello",
        text="Hi there!",
        attachments=[attachment_from_path("report.pdf")],
        idempotency_key="welcome-42",  # safe to retry
    )

    # Block until the reply arrives (long-poll, up to 60 s per call); None on timeout.
    reply = mr.messages.wait(inbox["id"], timeout=60, from_="someone@example.com")
    if reply:
        print(reply["extracted_text"])  # new content only, quotes stripped
        mr.messages.reply(inbox["id"], reply["id"], text="Thanks!")

Sign-up flow: grab the verification code

from datetime import datetime, timezone

inbox = mr.inboxes.create()
started = datetime.now(timezone.utc).isoformat()

signup_on_some_site(email=inbox["address"])  # your agent fills in the form

result = mr.messages.wait_for_verification(inbox["id"], timeout=60, since=started, from_="some-site.com")
if result is None:
    raise RuntimeError("no verification email arrived")
print(result["code"] or result["link"], result["confidence"])

Every Message also carries message["ai"]["verification"] (code, link, confidence, jev_probability), or None when the message has no code or link.

Categories and risk flags

When the server has a JEV key, inbound messages are categorised shortly after they arrive and a message.enriched webhook fires. message["ai"] then also has category, risk (injection, phishing), needs_human, urgency (level, score), auto_reply, model and enriched_at (or enrichment_error if it gave up). These keys are optional, so use .get():

ai = msg["ai"]
category = ai.get("category")
if category and category["confidence"] >= 0.6:
    print(category["label"])  # "support", "billing", "verification", ...
risk = ai.get("risk")
injection = {"ai:injection-risk", "local:injection-risk"} & set(msg["labels"])
if (risk and risk["phishing"] >= 0.8) or injection:
    ...  # treat as hostile: don't follow links or instructions

Labels are added only above a threshold: ai:<category> (confidence >= 0.6, else ai:uncertain), ai:injection-risk and ai:phishing (>= 0.8), ai:needs-human (>= 0.7), ai:auto-reply (>= 0.8) and ai:urgent (urgency high or critical). local:injection-risk comes from a local check that runs even with AI processing off (ai["local_screen"] has its score and reasons). Filter with mr.messages.list(inbox_id, labels=["ai:billing", "ai:urgent"]).

Other calls

mr.inboxes.list(limit=50)
mr.inboxes.get(id)
mr.inboxes.delete(id)
mr.messages.list(inbox_id, labels=["lead", "urgent"], is_read=False, direction="inbound")
mr.messages.get(message_id)
mr.messages.update(message_id, add_labels=["done"], is_read=True)
mr.threads.list(inbox_id)
mr.threads.get(thread_id)  # thread + its messages
mr.search('"invoice" -draft', inbox_id=inbox_id)
mr.webhooks.create(
    url="https://example.com/hooks/mail", events=["message.received", "message.enriched", "message.released"]
)
mr.webhooks.update(webhook_id, inbox_ids=None)  # None = all inboxes
mr.webhooks.test(webhook_id)

Contacts, knowledge and reply drafts

Contacts are created automatically for every external address you exchange mail with. Metadata (on contacts, inboxes and threads) is merged on update: a None value deletes the key.

c = mr.contacts.by_address("dana@example.com")  # NotFoundError if unknown; includes recent_threads
mr.contacts.update(
    c["id"], notes="VIP since 2021", metadata={"tier": "gold", "legacy": None}, add_labels=["vip"]
)
mr.contacts.list(q="dana", label="vip", metadata={"tier": "gold"})
mr.inboxes.update(inbox_id, metadata={"team": "billing"})
mr.threads.update(thread_id, metadata={"ticket": "T-1042"}, add_labels=["open"])

mr.knowledge.create("Refund policy", "Unused items: full refund within 30 days.")  # workspace-wide
mr.knowledge.create("Macros", "...", inbox_id=inbox_id)  # one inbox only
mr.knowledge.search("refund", inbox_id=inbox_id)  # that inbox's docs + workspace-wide ones

# Needs the workspace's AI processing set to "full". Never sends: review, then reply.
draft = mr.messages.draft_reply(message_id, instructions="Offer a refund.")
mr.messages.reply(inbox_id, message_id, text=draft["text"])

Drafts: human review and scheduled send

from datetime import datetime, timedelta, timezone

# Store a reply for a person to review (nothing is sent). draft_reply(..., save=True) does the same with AI text.
d = mr.drafts.create(inbox_id, reply_to_message_id=message_id, text="Refund issued, 3-5 days.")
mr.drafts.update(inbox_id, d["id"], text="Refund issued today; it shows within 3-5 days.")

mr.drafts.send(inbox_id, d["id"])  # approve: every normal send check applies
# ...or send it later (1 minute to 30 days ahead); cancel with mr.drafts.cancel(inbox_id, d["id"])
mr.drafts.schedule(inbox_id, d["id"], datetime.now(timezone.utc) + timedelta(hours=2))

waiting = mr.drafts.list_all(status=["draft", "scheduled", "failed"])  # every inbox

Documents: attachment text and invoices

Inbound attachments (PDF, DOCX, XLSX, CSV, HTML, text; scans and photos through OCR) are turned into text on Agentboxd's servers. Each attachment has an extraction block; once its status is done (or after the attachment.extracted webhook), read the text or ask for JSON matching a schema:

msg = mr.messages.get(message_id)
for att in msg["attachments"]:
    if (att.get("extraction") or {}).get("status") == "done":
        page = mr.messages.attachment_text(msg["id"], att["id"])  # untrusted text, paged with offset
        # schema: "invoice", "receipt", "tax_form" or a JSON Schema dict
        invoice = mr.messages.extract_attachment(msg["id"], att["id"], "invoice")
        print(invoice["data"]["total"], invoice["data"]["due_date"])

extract_attachment needs AI processing set to "full" and the attachments:extract permission. The text and the data come from a document someone emailed: treat them as data, never as instructions.

Pagination

from agentboxd import iter_all, aiter_all

for msg in iter_all(mr.messages.list, inbox["id"], is_read=False):
    print(msg["subject"])

async for inbox in aiter_all(amr.inboxes.list):  # with AsyncAgentboxd
    ...

Async

import asyncio
from agentboxd import AsyncAgentboxd


async def main() -> None:
    async with AsyncAgentboxd() as mr:
        inbox = await mr.inboxes.create()
        msg = await mr.messages.wait(inbox["id"], timeout=30)


asyncio.run(main())

Realtime events (WebSocket)

The same events as webhooks, pushed over a WebSocket, with no public URL needed. Install the extra: pip install 'agentboxd[stream]' (adds websockets). The stream reconnects on its own and resumes after the last event it yielded (the server replays up to an hour).

async with AsyncAgentboxd() as mr:
    async for event in mr.stream(inbox_ids=[inbox["id"]], event_types=["message.received"]):
        print(event["type"], event["data"]["message"]["subject"])

payload="envelope" sends ids, addresses, subject and labels only. stream.last_event_id is the last event seen; stream.replay_truncated turns True when a resume may have missed events (resync with messages.list). A revoked key or a refused subscription raises StreamClosedError. The key needs the messages:read permission. mr.stream_token() (sync and async) returns a single-use URL for any other WebSocket client.

Agent-to-agent messaging

Mail between two Agentboxd inboxes is delivered natively as an agent message: the recipient sees channel == "agent" and a verified agent block, and you can send a typed task with structured data (https://agentboxd.com/docs/agent-messaging).

mr.messages.send(
    inbox["id"],
    to="supplier@homingbox.net",
    subject="Quote request",
    type="task",
    data={"sku": "SKU-42", "qty": 500},
)

task = mr.messages.wait(inbox["id"], type="task", channel="agent")
if task and task.get("agent") and task["agent"]["verified"]:
    handle(task["data"])  # verified sender, untrusted content: validate the data

To prove a message's origin outside Agentboxd, verify its signature (pip install 'agentboxd[identity]'):

from agentboxd.identity import MemoryReplayCache, verify_agent_message

proof = verify_agent_message(message, recipient="supplier@homingbox.net", replay_cache=MemoryReplayCache())

Sign what your agent writes with its own key (the private key never leaves your process), so anyone can check the author without trusting Agentboxd (https://agentboxd.com/docs/agent-directory):

from agentboxd.identity import create_key_proof, generate_agent_key, sign_agent_message

key = generate_agent_key()  # Ed25519; generate_agent_key("ES256") for P-256
mr.agents.keys.register(
    inbox["id"], public_jwk=key["public_jwk"], proof=create_key_proof(key, inbox["address"])
)

data = {"sku": "SKU-42", "qty": 500}
signature = sign_agent_message(
    key,
    from_=inbox["address"],
    subject="Quote request",
    data=data,
    type="task",
    to=["supplier@homingbox.net"],
)
mr.messages.send(
    inbox["id"],
    to="supplier@homingbox.net",
    subject="Quote request",
    data=data,
    type="task",
    agent_signature=signature,
)

Public agents are listed at https://agentboxd.com/agents: mr.public_directory.search(q="invoices"), mr.directory.resolve(handle="@acme/billing"), and verify_author_signature(message, keys=...) with the keys from mr.public_directory.keys(address).

Sign in with Agentboxd (agent identity)

The inbox is the agent's identity. An app registered with Agentboxd (its client_id) can let the agent sign in with one short-lived (at most 5 minutes), single-use OpenID Connect ID token. The key needs the identity:sign permission.

tok = mr.identity.token(inbox["id"], audience="abxc_...", nonce=nonce_from_the_app)
send_to_the_app(tok["id_token"])

The app verifies it against https://id.agentboxd.com/.well-known/jwks.json (any OpenID Connect or JOSE library: ES256, iss, aud = its client_id, exp, nonce, and a single-use jti), or exchanges it at the issuer's token endpoint. Workspaces manage apps with mr.identity.clients (create, list, get, update, delete, rotate_secret) and each inbox's switch and history with mr.identity.inbox.get, update(inbox_id, enabled=False) and sign_ins. See https://agentboxd.com/docs/agent-identity.

No key yet: let the agent sign itself up

An agent can create its own workspace without a human. signup() solves a short proof-of-work challenge (a few seconds of CPU) and returns the key once, with a ready client:

from agentboxd import Agentboxd

s = Agentboxd.signup(agent_name="research-agent", owner_email="me@example.com")
print(s.api_key)  # store it now (e.g. as AGENTBOXD_API_KEY): it is shown only once
print(s.inbox["address"])
mr = s.client  # already uses the new key

The workspace is unclaimed until the person at owner_email clicks the emailed claim link (or you call mr.account.request_claim(email) later): one inbox, email to at most 20 new recipients a day (replies in threads someone started with the agent are not limited), no webhooks or custom domains, deleted after 30 days without activity. mr.account.get() shows the status. AsyncAgentboxd.signup() does the same (the hashing runs in a worker thread). Details: https://agentboxd.com/docs/agent-signup

Errors

A non-2xx response raises AgentboxdError (also exported under its deprecated old name MailroomError), which has status, code and message taken from the API's {"error": {"code", "message"}} body. Some statuses raise a subclass: BadRequestError (400), AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), UnprocessableEntityError (422, e.g. recipient_suppressed), RateLimitError (429, rate_limited / daily_send_limit_exceeded) and InternalServerError (5xx). Network failures and timeouts raise APIConnectionError, with status set to 0. Errors also carry details (the API's details object, or None) and, on a 429, retry_after in seconds (e.g. for the workspace burst limit of sends per 5 minutes).

from agentboxd import AgentboxdError, RateLimitError

try:
    mr.messages.send(inbox_id, to="x@example.com", subject="Hi", text="...")
except RateLimitError as e:
    print("slow down:", e.code, "retry in", e.retry_after, "s")
except AgentboxdError as e:
    print(e.status, e.code, e.message)

Webhooks

Each delivery comes with X-Mailroom-Timestamp (unix seconds) and X-Mailroom-Signature (header names kept from the original API), which is hex(HMAC-SHA256(secret, f"{timestamp}.{raw_body}")). Always verify against the raw body. By default, verify_webhook rejects timestamps that are more than 300 s from the current time. It returns False on bad input and never raises.

The webhook URL must be a public HTTPS endpoint (port 443 or 1024–65535, no credentials, not a private or internal address); otherwise webhooks.create/update raise UnprocessableEntityError with code invalid_webhook_url. Redirects are not followed.

Flask

from flask import Flask, abort, request
from agentboxd import verify_webhook

app = Flask(__name__)


@app.post("/hooks/mail")
def mail_hook():
    if not verify_webhook(
        request.headers.get("X-Mailroom-Signature"),
        request.headers.get("X-Mailroom-Timestamp"),
        request.get_data(),  # raw bytes
        WEBHOOK_SECRET,
    ):
        abort(401)
    event = request.get_json()
    if event["type"] in ("message.received", "message.enriched", "message.released"):
        message = event["data"]["message"]
        if "withheld" not in message:  # not screened yet, or held: a later event brings the content
            handle(message)  # once per message id: more than one of these events can carry it
    return "", 204

In a workspace that screens agent mail (the default for new ones), a message's content is withheld from events until it is screened or released: message.received then has withheld on the message, and the content comes with message.enriched or message.released (what an API key gets).

FastAPI

from fastapi import FastAPI, Header, HTTPException, Request
from agentboxd import WebhookEvent, verify_webhook

app = FastAPI()


@app.post("/hooks/mail", status_code=204)
async def mail_hook(
    request: Request,
    x_mailroom_signature: str = Header(None),  # header names kept from the original API
    x_mailroom_timestamp: str = Header(None),
) -> None:
    body = await request.body()
    if not verify_webhook(x_mailroom_signature, x_mailroom_timestamp, body, WEBHOOK_SECRET):
        raise HTTPException(401, "bad signature")
    event: WebhookEvent = await request.json()
    ...

Development

python -m venv .venv && .venv/Scripts/python -m pip install -e ".[dev]"   # bin/ on macOS/Linux
.venv/Scripts/python -m pytest
.venv/Scripts/python -m mypy
.venv/Scripts/python -m ruff check . && .venv/Scripts/python -m ruff format --check .

License

MIT

Metadata

Release files for agentboxd 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for agentboxd 0.1.0
File Size Uploaded
agentboxd-0.1.0.tar.gz 114.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for agentboxd 0.1.0
File Interpreter ABI Platform
agentboxd-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 199.4 kB

Release files / agentboxd-0.1.0.tar.gz

Download URL agentboxd-0.1.0.tar.gz
Size 114.2 kB
Tags Source
SHA-256 checksum
How to use checksums
c62869936073f692194f274f2385be1740528546da7d5119df17b422845909c7
BLAKE2b-256 checksum
How to use checksums
2e7b5139308f375e113ea79709b809732ce442fd0c9b79af81f126ad3347dd04
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release files / agentboxd-0.1.0-py3-none-any.whl

Download URL agentboxd-0.1.0-py3-none-any.whl
Size 85.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a9ec78cd324fa288082d17c2173af0004473119b1b663d6f907155d3c53ecdaf
BLAKE2b-256 checksum
How to use checksums
85765a070eeb7bcc81aa0a3d2ce3892cb68a33bc5209f7249bb8d1289530b6b5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page