Quickstart
A working inbox and a sent email, in two calls.
Two ways in. Pick the one that matches who is doing it.
For people
1. Open the console. If you already have an account, enter its owner email and use the six-digit sign-in code. No API key to find. For a new account, choose Create one, enter an inbox name, and confirm the code sent to you.
2. Copy the key. The console shows it once, on the confirmation step. Store it then — nothing can show that same key to you again. If it is lost, email-code sign-in issues a replacement without revoking the keys your agents are using.
3. Put it somewhere your code can read it.
export CARLYEMAIL_API_KEY=ce_us_...
Then jump to sending.
For agents
No console, no dashboard. Sign-up is a single unauthenticated call, which is what lets an agent get its own mailbox while it is running.
TypeScript
import { CarlyEmail } from "carlyemail";
const account = await new CarlyEmail().agent.signUp({
human_email: "you@example.com",
username: "hello",
});
Python
from carlyemail import CarlyEmail
account = CarlyEmail().agent.sign_up(
{"human_email": "you@example.com", "username": "hello"}
)
cURL
curl -X POST https://api.carlyemail.com/v0/agent/sign-up \
-H 'content-type: application/json' \
-d '{"human_email": "you@example.com", "username": "hello"}'
Response
{
"organization_id": "org_00ms93lsavnm83rw519vzpgw",
"inbox_id": "hello@agents.carlyemail.com",
"api_key": "ce_us_dvfstqsgs0k7f0o7kkxafozcitwrv46cpp9tas03"
}
No key on the constructor — this is the one call that does not take one.
Warning
The key is returned once and stored only as a hash. Nothing will show it to you again. Put it somewhere you can read back — a store that accepts secrets but will not return them leaves you signing in from scratch every session, and signing in means an emailed code and a human to relay it.
An existing owner email is not a credential: repeating sign-up returns
account_exists. The owner recovers access with an emailed code — carlyemail
signin from a terminal (the CLI's signup falls through to it on its own), or
the console in a browser.
Confirm the address. A six-digit code goes to the human_email. Until it is
confirmed the key reads and organizes mail and can email only the owner —
enough for the agent to introduce itself and prove the loop works. Confirming
opens sending to everyone else.
TypeScript
const carly = new CarlyEmail({ apiKey: account.api_key });
await carly.agent.verify({ otp_code: "307354" });
Python
carly = CarlyEmail(api_key=account["api_key"])
carly.agent.verify({"otp_code": "307354"})
cURL
curl -X POST https://api.carlyemail.com/v0/agent/verify \
-H "Authorization: Bearer $CARLYEMAIL_API_KEY" \
-H 'content-type: application/json' \
-d '{"otp_code": "307354"}'
Note
Confirming does not rename the mailbox. A domain is chosen when an address is
minted, and an address is never rewritten — so hello@agents.carlyemail.com
stays what it is, and the next inbox you create lands on carlyemail.com. The
verify response names both: inbox_domain and inboxes_on_signup_domain. See
inboxes.
Send
TypeScript
await carly.messages.send("hello@agents.carlyemail.com", {
to: ["someone@example.org"],
subject: "Hello",
text: "Sent from an agent.",
});
Python
carly.messages.send(
"hello@agents.carlyemail.com",
{"to": ["someone@example.org"], "subject": "Hello", "text": "Sent from an agent."},
)
CLI
npx carlyemail send --from hello@agents.carlyemail.com \
--to someone@example.org --subject "Hello" --text "Sent from an agent."
Note
Two things refuse a send before it leaves, and both say so in the error:
organization_unverified— the owner has not confirmed the code yet, so the only address this account can write to is the owner's own.recipient_not_allowed— the account has a send allow list, and the recipient is not on it. An allow list with anyone on it refuses everyone else. Add the address withPOST /v0/lists/send/allow, or empty the list to send anywhere. See lists.
Read what comes back
TypeScript
const { messages } = await carly.messages.list("hello@agents.carlyemail.com");
await carly.messages.reply("hello@agents.carlyemail.com", messages[0].message_id, {
text: "On it.",
});
Python
messages = carly.messages.list("hello@agents.carlyemail.com")["messages"]
carly.messages.reply(
"hello@agents.carlyemail.com", messages[0]["message_id"], {"text": "On it."}
)
CLI
npx carlyemail messages hello@agents.carlyemail.com
npx carlyemail reply hello@agents.carlyemail.com --last --text "On it."
Replying to a message puts your answer in the sender's existing thread.
Tip
Read extracted_text rather than text when handing a message to a model. It
has the quoted chain stripped, so the model sees the new message instead of the
whole history repeated.
Warning
list does not return bodies. It gives you headers, labels and a short
preview; text, html and extracted_text are absent from every item. Fetch
the ones you actually want to read with get, or the whole page of them in one
call with batch-get. Reading a body straight off a list item yields an empty
string, and an agent built on that replies to nothing at all.
Polling is fine to start with and wrong in production. Use a webhook to wake a sleeping server, or a WebSocket for an agent or console that is already running.
Hand this to your coding agent
Paste one of these into Claude Code, Cursor or anything else. It is the whole integration in one block: setup, the calls, errors, and the traps.
Python
"""
CarlyEmail — real email inboxes for AI agents. pip install carlyemail
Sign-up (no key needed): CarlyEmail().agent.sign_up({"human_email": ..., "username": ...})
-> {"api_key", "inbox_id", "organization_id"}. Then agent.verify({"otp_code": ...})
with that key. Unverified accounts can read, and send ONLY to their owner.
inboxes.create({"username", "domain"?, "client_id"?}) client_id makes retries idempotent
inboxes.list() / .get(inbox) / .delete(inbox)
messages.list(inbox, limit=, labels=) / .get(inbox, message_id) / .search(inbox, q=)
messages.batch_get(inbox, {"message_ids": [...]}) bodies for a page, in one call
messages.send(inbox, {"to": [...], "subject", "text", "html"?, "cc"?, "attachments"?})
messages.reply(inbox, message_id, {"text"}) stays in the sender's thread
messages.reply_all(inbox, message_id, {"text"}) recipients come from the message
drafts.create(inbox, {...}) / drafts.send(inbox, draft_id) write now, send later
webhooks.create({"url", "event_types": ["message.received"]}) secret returned ONCE
Traps, in the order people hit them:
- list() returns NO body: no text, no html, no extracted_text, just a preview.
Read one with get(), or a page of them with batch_get(), before using a body.
- Reply to a MESSAGE, never compose a new one, or it starts a second thread.
- Read extracted_text, not text: text repeats the whole quoted history.
- Inbox ids are email addresses; message ids are <angle@bracketed> Message-IDs.
- Verify webhook signatures with hmac.compare_digest, not ==.
- 429 means a plan limit; error.code names which one, error.fix says what clears it.
"""
import os
from carlyemail import CarlyEmail, CarlyEmailError
carly = CarlyEmail() # reads CARLYEMAIL_API_KEY
inbox = carly.inboxes.create({"username": "hello", "client_id": "my-agent-v1"})
try:
carly.messages.send(
inbox["email"],
{"to": ["someone@example.org"], "subject": "Hello", "text": "From an agent."},
)
except CarlyEmailError as error:
print(error.status, error.code, error.fix, error.docs)
raise
listed = carly.messages.list(inbox["email"], limit=10, labels=["received"])["messages"]
full = carly.messages.batch_get(
inbox["email"], {"message_ids": [m["message_id"] for m in listed]}
)["messages"]
for message in full:
body = message.get("extracted_text") or message.get("text") or ""
carly.messages.reply(inbox["email"], message["message_id"], {"text": answer(body)})
TypeScript
/**
* CarlyEmail — real email inboxes for AI agents. npm install carlyemail
*
* Sign-up (no key needed): new CarlyEmail().agent.signUp({ human_email, username })
* -> { api_key, inbox_id, organization_id }. Then agent.verify({ otp_code }) with
* that key. Unverified accounts can read, and send ONLY to their owner.
*
* inboxes.create({ username, domain?, client_id? }) client_id makes retries idempotent
* inboxes.list() / .get(inbox) / .delete(inbox)
* messages.list(inbox, { limit, labels }) / .get(inbox, messageId) / .search(inbox, { q })
* messages.batchGet(inbox, { message_ids: [...] }) bodies for a page, in one call
* messages.send(inbox, { to: [...], subject, text, html?, cc?, attachments? })
* messages.reply(inbox, messageId, { text }) stays in the sender's thread
* messages.replyAll(inbox, messageId, { text }) recipients come from the message
* drafts.create(inbox, {...}) / drafts.send(inbox, draftId) write now, send later
* webhooks.create({ url, event_types: ["message.received"] }) secret returned ONCE
*
* Traps, in the order people hit them:
* - list() returns NO body: no text, no html, no extracted_text, just a preview.
* Read one with get(), or a page of them with batchGet(), before using a body.
* - Reply to a MESSAGE, never compose a new one, or it starts a second thread.
* - Read extracted_text, not text: text repeats the whole quoted history.
* - Inbox ids are email addresses; message ids are <angle@bracketed> Message-IDs.
* - Verify webhook signatures with a constant-time compare, not ===.
* - 429 means a plan limit; error.code names which one, error.fix says what clears it.
*/
import { CarlyEmail, CarlyEmailError } from "carlyemail";
const carly = new CarlyEmail(); // reads CARLYEMAIL_API_KEY
const inbox = await carly.inboxes.create({ username: "hello", client_id: "my-agent-v1" });
try {
await carly.messages.send(inbox.email, {
to: ["someone@example.org"],
subject: "Hello",
text: "From an agent.",
});
} catch (error) {
if (error instanceof CarlyEmailError) console.log(error.code, error.fix, error.docs);
throw error;
}
const { messages: listed } = await carly.messages.list(inbox.email, {
limit: 10,
labels: ["received"],
});
const { messages } = await carly.messages.batchGet(inbox.email, {
message_ids: listed.map((m) => m.message_id),
});
for (const message of messages) {
const body = message.extracted_text ?? message.text ?? "";
await carly.messages.reply(inbox.email, message.message_id, { text: answer(body) });
}
Every page here is also served as Markdown — add .md to any URL — and
llms.txt indexes the lot.