Example: an agent that answers its own mail
Receive a message, decide what to say, reply in thread. About sixty lines.
The smallest useful thing you can build: an inbox that reads what arrives and answers it. Everything else in these docs is a variation on this loop.
What it does
- A webhook fires when mail arrives
- The agent reads the message
- It writes a reply and sends it into the same thread
1. An inbox and a key
npx carlyemail signup --human-email you@example.com --username support
npx carlyemail verify 123456
You now have support@carlyemail.com and a key in ~/.carlyemail/config.json.
2. Point a webhook at your code
While you are developing, expose a local port with something like ngrok, then:
curl -X POST https://api.carlyemail.com/v0/webhooks \
-H "authorization: Bearer $CARLYEMAIL_API_KEY" \
-H 'content-type: application/json' \
-d '{"url": "https://your-tunnel.example/hooks/mail",
"event_types": ["message.received"]}'
The response includes a secret. Keep it — it is returned once, and step 4
needs it.
3. The agent
Python
import os, hmac, hashlib, httpx
from fastapi import FastAPI, Request, HTTPException
API = "https://api.carlyemail.com"
KEY = os.environ["CARLYEMAIL_API_KEY"]
SECRET = os.environ["CARLYEMAIL_WEBHOOK_SECRET"]
app = FastAPI()
carly = httpx.Client(base_url=API, headers={"authorization": f"Bearer {KEY}"})
@app.post("/hooks/mail")
async def on_mail(request: Request):
raw = await request.body()
if not valid(raw, request.headers.get("carlyemail-signature", "")):
raise HTTPException(400, "bad signature")
event = await request.json()
if event.get("event_type") != "message.received":
return {"ok": True}
message = event["data"]
# `extracted_text` is the new content with the quoted reply chain stripped,
# which is what you want to hand a model. `text` is the whole thing.
question = message.get("extracted_text") or message.get("text") or ""
carly.post(
f"/v0/inboxes/{message['inbox_id']}/messages/{message['message_id']}/reply",
json={"text": answer(question)},
)
return {"ok": True}
def answer(question: str) -> str:
# Your model goes here. Anything that turns text into text works.
return f"Thanks for writing. You asked:\n\n> {question[:200]}\n\nWe are on it."
Replying to a message rather than composing a new one is what keeps the
conversation together: CarlyEmail sets In-Reply-To and References for you, so
the recipient's mail client threads it instead of starting a second conversation.
4. Verify the signature
Never skip this. Your webhook URL is reachable by anyone who guesses it, and without verification any of them can make your agent send mail.
Python
def valid(raw: bytes, header: str) -> bool:
expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
compare_digest, not == — a plain comparison leaks how much of the signature
was right through how long it took to fail.
5. Try it
Send the inbox an email from your own mail client. You should get a reply within a second or two.
npx carlyemail messages support@carlyemail.com
Where to go next
- Human in the loop — the same agent, but a person approves before anything leaves
- Webhooks — every event type, retries, and what a delivery looks like
- Labels — mark what you have handled, so a restart does not answer the same mail twice