CarlyEmail docs

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 with POST /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.

Next