An agent working in a terminal can get its own email address, send from it, read the replies and answer inside the same conversation, with no server to run and no sending domain to set up. This walkthrough covers that full round trip from a shell, shows the raw HTTP request behind each step, and explains how an agent can look up the real commands instead of inventing them.
Most scripts that send email never hear back. A CI job posts a build report, a cron task mails a summary, and any reply lands in an address nobody reads. That works for notifications. It breaks the moment the person reading the email is supposed to answer and the program is supposed to act on that answer.
Coding agents hit this wall early. An agent in a terminal can write code and call HTTP endpoints. It rarely has a mailbox of its own. Most email tools expect you to verify a domain or connect someone's personal account first. Standing up a backend to receive mail for a ten-line script is a bad trade.
The steps below go from nothing to a working conversation, all from a shell. You create an address, send a message, read the reply and answer it in the same thread. Each step also shows the HTTP request underneath, for machines where installing another binary is not an option.
What is the fastest way to send and receive email from a shell?
The fastest way to send and receive email from a shell is to give the script its own inbox with the AgentMail CLI. One command creates an address on agentmail.to, with no DNS records to publish and no domain to verify. A second command sends from it. Two more find and read what came back, and a last one replies so the answer joins the original thread.
Each command wraps one REST call, so anything the CLI does, curl can do with the same API key. Output is a table in a terminal and JSON when piped, which means scripts and agents get parseable output without extra flags. That is the whole loop: five commands and one environment variable.
Pick the tool by situation. Interactive work in a terminal: the CLI, because its errors and help are readable. A slim container with no package manager: curl. Sending only, from a domain you already verified: Resend's CLI fits, since its --from address must be on a verified domain.
Prerequisites
You need the CLI at a pinned version, an API key, and jq for pulling fields out of JSON. The CLI is a single native binary with no runtime dependencies. On macOS and Linux, install it from npm. Windows is the exception. The npm package does not install a Windows binary yet, so use the PowerShell installer on the CLI releases page, which fetches the x64 build on ARM64 machines.
Create a key in the AgentMail Console and export it as AGENTMAIL_API_KEY. The free plan has 3 inboxes and 3,000 emails a month, up to 100 a day, and needs no credit card. To keep the key across new shells, store it in the OS keychain with agentmail auth login --with-token --scheme BearerAuth instead.
An agent can also sign itself up with agentmail agent sign-up --human-email you@example.com --username my-agent, which returns an API key, an inbox ID and an organization ID. The human gets a six-digit code to confirm. Leave the human email out and the inbox is receive-only until a person is attached.
# install the pinned CLI, then confirm the version and which credential is active
npm install -g agentmail-cli@1.9.0
agentmail --version
agentmail auth status | jq -c '.schemes[0].sources[]'
# agentmail 1.9.0
# {"source":"AGENTMAIL_API_KEY env var","state":"active"}
# {"source":"keyring entry agentmail:BearerAuth","state":"missing"}Create an inbox with one command
An inbox is an email address with its own message store, created through the API instead of a mail admin panel. Pass a username and a display name, and the domain defaults to agentmail.to. Leave the username out and you get a random one, which suits throwaway inboxes in CI. A verified custom domain goes in --domain instead.
The inbox ID is the address itself. Every later command takes it as --inbox-id. No second identifier to track.
# create an inbox on agentmail.to and print the two fields later steps use
agentmail inboxes create --username release-bot --display-name "Release Bot" \
--query '{inbox_id: inbox_id, email: email}'
# {
# "email": "release-bot@agentmail.to",
# "inbox_id": "release-bot@agentmail.to"
# }Send an email from the terminal
Sending takes the inbox ID, a recipient, a subject and a body, and returns two IDs worth keeping. The message_id names this email. The thread_id names the conversation it starts, and replies to it join that thread automatically. The whole request, body and attachments together, is capped at 6 MB.
When a step can rerun, as CI steps do, add --idempotency-key with a value unique to that send. A retry with the same key returns the original message instead of sending a second email. Keys expire 24 hours after the send completes.
# send from the new inbox and keep the thread ID for the reading step
THREAD_ID=$(agentmail inboxes messages send \
--inbox-id release-bot@agentmail.to \
--to maya@example.com \
--subject "Build 412 is green" \
--text "All 38 tests passed. Reply ship to deploy." \
--idempotency-key "build-412-report" \
| jq -r .thread_id)
echo "$THREAD_ID"
# prints the thread_id from the response, which also carries message_idRead the reply and list the thread
Reading mail takes two calls. A list call finds the message, and a get call returns it. The list puts the newest messages first. It filters by a substring of the sender, recipient or subject, so an agent waiting on Maya can ask for her mail only. The full message includes extracted_text: the new text of a reply with the quoted history stripped out.
# list Maya's newest messages, then read only the new text of the latest one
agentmail inboxes messages list --inbox-id release-bot@agentmail.to \
--from maya@example.com --limit 5 \
--query 'messages[].{from: from, subject: subject}'
MSG_ID=$(agentmail inboxes messages list --inbox-id release-bot@agentmail.to \
--from maya@example.com --limit 1 | jq -r '.messages[0].message_id')
agentmail inboxes messages get --inbox-id release-bot@agentmail.to \
--message-id "$MSG_ID" --query extracted_text
# [
# {
# "from": "Maya Chen <maya@example.com>",
# "subject": "Re: Build 412 is green"
# }
# ]
# "ship"The thread view puts the conversation in order. Messages come back oldest first within a page of up to 100. The first page holds the newest ones. A long thread pages backwards with --page-token.
# read the whole conversation in order, with the sender and preview of each message
agentmail inboxes threads get --inbox-id release-bot@agentmail.to \
--thread-id "$THREAD_ID" \
--query '{count: message_count, messages: messages[].[from, preview]}'
# {
# "count": 2,
# "messages": [
# ["Release Bot <release-bot@agentmail.to>", "All 38 tests passed. Reply ship to deploy."],
# ["Maya Chen <maya@example.com>", "ship"]
# ]
# }Reply inside the same thread
A reply targets a message, and AgentMail files it in that message's thread. You pass the inbox ID, the message ID from the reading step and the text. Use reply-all instead when the original had other recipients who should see the answer.
# answer Maya in the same thread, then mark her message so a rerun skips it
agentmail inboxes messages reply --inbox-id release-bot@agentmail.to \
--message-id "$MSG_ID" --text "Deploying build 412 now." \
--idempotency-key "build-412-ack" --query thread_id
agentmail inboxes messages update --inbox-id release-bot@agentmail.to \
--message-id "$MSG_ID" --add-labels replied --remove-labels unreplied
# the reply prints the same thread_id as $THREAD_IDLabels are how a polling agent remembers what it already answered. They live on the message, not in your script. Swapping unreplied for replied after each answer keeps a second pass from replying twice. If a job dies after the reply but before the label update, rerunning it with the same idempotency key returns the original reply instead of sending Maya a second email. How a reply names its parent through the In-Reply-To and References headers is covered in how email threading works for AI agents.
The same calls with curl
Every command above is one HTTPS request to https://api.agentmail.to with a bearer token, under the /v0 path. No SDK is involved. Request bodies are JSON and match the CLI flags with underscores, so --display-name becomes display_name. The inbox ID can sit in the path as an address.
# create the inbox and send the first message with plain curl
curl -s -X POST https://api.agentmail.to/v0/inboxes \
-H "Authorization: Bearer $AGENTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"username": "release-bot", "display_name": "Release Bot"}'
# returns the inbox object, including inbox_id and email
curl -s -X POST https://api.agentmail.to/v0/inboxes/release-bot@agentmail.to/messages/send \
-H "Authorization: Bearer $AGENTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: build-412-report" \
-d '{"to": "maya@example.com", "subject": "Build 412 is green", "text": "All 38 tests passed. Reply ship to deploy."}'
# returns message_id and thread_idMessage IDs contain characters that are not safe in a URL path, so encode them before building the reply URL. The CLI does this for you. With curl, jq's @uri filter does it in one line.
# list Maya's mail, encode the latest message ID, and reply to it with curl
curl -s -G https://api.agentmail.to/v0/inboxes/release-bot@agentmail.to/messages \
-H "Authorization: Bearer $AGENTMAIL_API_KEY" \
--data-urlencode "from=maya@example.com" -d limit=5
# returns count, messages (newest first) and next_page_token when more exist
MSG_PATH=$(jq -rn --arg id "$MSG_ID" '$id | @uri')
curl -s -X POST "https://api.agentmail.to/v0/inboxes/release-bot@agentmail.to/messages/$MSG_PATH/reply" \
-H "Authorization: Bearer $AGENTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: build-412-ack" \
-d '{"text": "Deploying build 412 now."}'
# returns message_id and the thread_id of the original sendLet an agent discover commands instead of guessing them
An agent that writes commands from memory will eventually type one that does not exist. The wrong command will look plausible. Version 1.0 of the CLI changed inboxes:messages send to inboxes messages send, and an agent that learned from older examples may still reach for the colon form. The fix is to have the agent read the CLI's own catalog before it acts.
The catalog is built in. agentmail --help --format json lists all 159 operations in 1.9.0, each with its HTTP method and path. --schema on any command prints its inputs and output as JSON. Think of it as --help for agents.
# ask the CLI what exists: count operations, list the message ones, read one command's flags
agentmail --help --format json | jq '.operations | length'
agentmail --help --format json \
| jq -r '.operations[].operation | select(startswith("inboxes.messages."))'
agentmail inboxes messages reply --schema | jq -c '[.input.properties[].flag]'
# 159
# inboxes.messages.batch-get
# inboxes.messages.batch-update
# inboxes.messages.delete
# inboxes.messages.forward
# inboxes.messages.get
# inboxes.messages.get-attachment
# inboxes.messages.get-raw
# inboxes.messages.list
# inboxes.messages.reply
# inboxes.messages.reply-all
# inboxes.messages.search
# inboxes.messages.send
# inboxes.messages.update
# ["--idempotency-key","--attachments","--bcc","--cc","--headers","--html","--inbox-id","--labels","--message-id","--reply-all","--reply-to","--text","--to","--track-opens"]Mistakes fail locally and say how to fix them. An unknown subcommand returns a JSON error with a suggestion and exit status 3. Flag values are type-checked before any request, so --limit abc never reaches the API.
--dry-run prints the exact method, URL and body without sending. Nothing leaves the machine. It is not an acceptance guarantee: the API can still reject an unknown ID or a missing permission.
# a typo fails with a hint, and --dry-run shows the request without sending it
agentmail inboxes messgaes list --inbox-id release-bot@agentmail.to | jq -c .error.help
agentmail inboxes messages send --dry-run --inbox-id release-bot@agentmail.to \
--to maya@example.com --subject "Build 412 is green" --text "All 38 tests passed." \
| jq -c '{method, url, body}'
# "a similar subcommand exists: 'messages'"
# {"method":"POST","url":"https://api.agentmail.to/v0/inboxes/release-bot%40agentmail.to/messages/send","body":{"subject":"Build 412 is green","text":"All 38 tests passed.","to":"maya@example.com"}}For agents that load skills, agentmail generate-skills --output-dir skills writes AgentSkills-compatible files from the same command surface. Setup for specific coding agents, such as AgentMail in Claude Code, lives on the build pages. The case for designing the CLI around agents first is in we do not build for humans.
What AgentMail does not do from the command line
The CLI has no listen or watch command, so new mail never streams into the shell. A script either polls with messages list, or a long-running process receives events through a webhook or a WebSocket, as in building real-time agents with webhooks. List commands also return one page at a time, so collecting everything means looping on next_page_token with --page-token.
AgentMail is the first email provider built for AI agents. Create an inbox in one API call, send and receive email, no OAuth and no domain verification. Free tier, no credit card required.


