Docs
An agent talks to ZeroIdentity over MCP or plain HTTP. Both expose the same 12 tools, and both use the agent's API key. This page covers all of it.
Reading this as an agent? The same page is plain Markdown at /docs.md, and /llms.txt has the short version.
Quickstart
- Create an agent. Sign in with Google, GitHub or an email address, give the agent a name, and it gets an address like
atlas@zeroidentity.sh. - Open its Connect tab and copy the command there. It already contains the agent's API key.
- Paste it into your agent. For Claude Code that is one line in a terminal.
- Ask the agent “what is your email address?” It will call
whoamiand tell you.
claude mcp add --transport http zeroidentity \
https://zeroidentity.app/mcp \
--header "Authorization: Bearer $ZEROIDENTITY_KEY"The examples on this page read the key from an ZEROIDENTITY_KEY environment variable. Keys start with zid_ and belong to one agent. Rotate a key from the Connect tab if it leaks.
MCP
The MCP server is at https://zeroidentity.app/mcp. It speaks Streamable HTTP and keeps no session, so every request stands on its own. Send the API key as a bearer token.
Any client that accepts a remote server with custom headers will work. Use the claude mcp add command above for Claude Code, or the JSON form for clients that read a config file.
HTTP API
Every tool is an endpoint at https://zeroidentity.app/v1/<tool>. Send arguments as a JSON body with POST, or as a query string with GET. Responses are JSON.
curl "https://zeroidentity.app/v1/whoami" \
-H "Authorization: Bearer $ZEROIDENTITY_KEY"Sending is safe to retry. Pass an idempotency_key to send_email or send_sms and a repeat with the same key returns the first message instead of sending a second one.
Receiving messages
wait_for_message is the simplest way to listen. It returns unread messages at once if there are any, and otherwise holds the request open until something arrives or the timeout passes. What it returns is marked read, so a loop sees each message exactly once.
const api = "https://zeroidentity.app/v1";
const headers = { Authorization: `Bearer ${process.env.ZEROIDENTITY_KEY}` };
while (true) {
const res = await fetch(`${api}/wait_for_message?timeout_seconds=50`, { headers });
const { messages } = await res.json();
for (const message of messages) {
await handle(message);
}
}When an email or a text plainly contains a one-time code, the message carries it in a code field, digits only. Your agent can use it without parsing the body.
Event delivery
If you would rather be called than poll, subscribe a URL with subscribe or from the Connect tab. Each inbound message is then sent to it as a POST.
{
"type": "message.received",
"agent": {
"id": "agt_60c75afc15f20fc3",
"name": "Atlas",
"email": "atlas@zeroidentity.sh"
},
"message": {
"id": "msg_1a2b3c4d5e6f7a8b",
"channel": "email",
"direction": "inbound",
"from": "noreply@github.com",
"to": "atlas@zeroidentity.sh",
"subject": "Your sign-in code",
"body": "Your verification code is 482 913",
"code": "482913",
"status": "received",
"unread": true,
"at": "2026-10-03T18:04:11.000Z"
}
}Each request carries X-ZeroIdentity-Signature, an HMAC-SHA256 of the raw body made with the subscription's secret. Check it before you trust the payload.
import crypto from "node:crypto";
// body is the raw request body, before any JSON parsing.
function verify(body: string, header: string, secret: string) {
const mac = crypto.createHmac("sha256", secret).update(body).digest("hex");
const expected = Buffer.from("sha256=" + mac);
const given = Buffer.from(header);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}Answer with any 2xx status within eight seconds. Anything else is retried up to five more times with growing gaps, from about ten seconds to two hours, then dropped. The X-ZeroIdentity-Delivery header stays the same across retries, so you can ignore a delivery you have already handled.
Inbound webhook
Every agent also has a URL of its own, shown in whoami and on the Connect tab. Anything posted to it lands in the agent's inbox as a message. If the payload is JSON with from, subject and text fields they are used. Otherwise the payload is kept as it is.
curl https://zeroidentity.app/hooks/hk_… \
-d '{"from": "ci", "subject": "Build finished", "text": "main is green"}'SSH key
Each agent has an ed25519 key. The public half is in whoami and on the Connect tab. Add it to GitHub or a server the way you would add anyone's key. The agent can sign with it through sign, or fetch the private key to use with ssh and git.
# Save the key where ssh and git can use it.
curl -s https://zeroidentity.app/v1/get_private_key -H "Authorization: Bearer $ZEROIDENTITY_KEY" \
| jq -r .private_key > ~/.ssh/agent_ed25519
chmod 600 ~/.ssh/agent_ed25519
ssh -i ~/.ssh/agent_ed25519 -T git@github.comPhone numbers
Texts and calls are in early access. An agent has a phone number only once its owner has asked for access from the Connect tab and been given it. Until then whoami returns phone: null, and send_sms and make_call answer with an error that says so. Everything else works without one.
Tool reference
The same list is served over MCP and at /v1. Arguments marked required must be present.
whoami
Who you are: your name, email address, phone number if you have one, public SSH key and inbound webhook URL.
curl "https://zeroidentity.app/v1/whoami" \
-H "Authorization: Bearer $ZEROIDENTITY_KEY"send_email
Send an email from your address. To reply in a thread, reuse its subject prefixed with "Re: ".
- tostring, required
- Recipient email address.
- subjectstring, required
- Subject line.
- bodystring, required
- Plain-text body.
- idempotency_keystring
- Optional. Any unique string. Sending again with the same key returns the first message instead of sending twice.
curl https://zeroidentity.app/v1/send_email \
-H "Authorization: Bearer $ZEROIDENTITY_KEY" \
-H "Content-Type: application/json" \
-d '{"to":"dana@acme.dev","subject":"Access request","body":"Could you add me to the acme org?"}'send_sms
Send a text message from your phone number. Needs a phone number, which is in early access; whoami shows whether you have one.
- tostring, required
- Recipient phone number in E.164 format, e.g. +14155550123.
- bodystring, required
- Message text.
- idempotency_keystring
- Optional. Any unique string. Sending again with the same key returns the first message instead of sending twice.
curl https://zeroidentity.app/v1/send_sms \
-H "Authorization: Bearer $ZEROIDENTITY_KEY" \
-H "Content-Type: application/json" \
-d '{"to":"+14155550123","body":"On my way."}'make_call
Place a phone call from your number (early access, like send_sms). Your words are spoken to whoever answers; anything they say back is transcribed into the call's `reply` field, readable later with get_message.
- tostring, required
- Phone number to call in E.164 format.
- saystring, required
- What to say when the call is answered.
curl https://zeroidentity.app/v1/make_call \
-H "Authorization: Bearer $ZEROIDENTITY_KEY" \
-H "Content-Type: application/json" \
-d '{"to":"+14155550123","say":"Hello, this is Atlas, calling to confirm Thursday's delivery."}'list_messages
List your recent messages across email, SMS, calls and webhooks, newest first. Does not mark anything as read.
- channelstring
- Only this channel. Omit for all. One of email, sms, call, webhook.
- directionstring
- Only received or only sent messages. One of inbound, outbound.
- unread_onlyboolean
- Only inbound messages you have not read yet.
- limitnumber
- How many to return (default 20, max 100).
- beforestring
- Only messages older than this message id. Use it to page back.
curl "https://zeroidentity.app/v1/list_messages?channel=email&unread_only=true" \
-H "Authorization: Bearer $ZEROIDENTITY_KEY"get_message
Fetch one message by id and mark it as read.
- idstring, required
- Message id, e.g. msg_1a2b3c.
curl "https://zeroidentity.app/v1/get_message?id=msg_1a2b3c4d5e6f7a8b" \
-H "Authorization: Bearer $ZEROIDENTITY_KEY"wait_for_message
Wait for incoming messages. Returns unread inbound messages right away if there are any, otherwise blocks until one arrives or the timeout passes. Returned messages are marked as read, so calling this in a loop yields each message once.
- channelstring
- Only this channel. Omit for all. One of email, sms, call, webhook.
- timeout_secondsnumber
- How long to wait (default 25, max 50).
curl "https://zeroidentity.app/v1/wait_for_message?timeout_seconds=25" \
-H "Authorization: Bearer $ZEROIDENTITY_KEY"sign
Sign data with your agent key (ed25519). Returns a base64 signature that verifies against your public key.
- datastring, required
- The exact text to sign.
curl https://zeroidentity.app/v1/sign \
-H "Authorization: Bearer $ZEROIDENTITY_KEY" \
-H "Content-Type: application/json" \
-d '{"data":"hello"}'get_private_key
Your agent key as an OpenSSH private key, for ssh and git. Write it to a file with mode 600 and never share it. The matching public key is in whoami.
curl "https://zeroidentity.app/v1/get_private_key" \
-H "Authorization: Bearer $ZEROIDENTITY_KEY"subscribe
Have every inbound message POSTed to a URL as JSON. Requests carry an X-ZeroIdentity-Signature header: sha256=HMAC(secret, body). Failed deliveries are retried for a few hours.
- urlstring, required
- The URL to deliver events to.
curl https://zeroidentity.app/v1/subscribe \
-H "Authorization: Bearer $ZEROIDENTITY_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/zeroidentity-events"}'list_subscriptions
List your webhook subscriptions.
curl "https://zeroidentity.app/v1/list_subscriptions" \
-H "Authorization: Bearer $ZEROIDENTITY_KEY"unsubscribe
Remove a webhook subscription.
- idstring, required
- Subscription id from list_subscriptions.
curl https://zeroidentity.app/v1/unsubscribe \
-H "Authorization: Bearer $ZEROIDENTITY_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"sub_1a2b3c4d5e6f"}'The message object
Email, texts, calls and webhook payloads all come back in one shape.
{
"id": "msg_1a2b3c4d5e6f7a8b",
"channel": "email",
"direction": "inbound",
"from": "noreply@github.com",
"to": "atlas@zeroidentity.sh",
"subject": "Your sign-in code",
"body": "Your verification code is 482 913",
"code": "482913",
"status": "received",
"unread": true,
"at": "2026-10-03T18:04:11.000Z"
}- channel
- email, sms, call or webhook.
- direction
- inbound or outbound.
- from, to
- Addresses for email, E.164 numbers for texts and calls.
- code
- A one-time code found in the message. Present only when there is one.
- status
- received for inbound. For outbound: sent, delivered (to another agent), queued, completed, failed, or sandbox when the deployment is not sending for real.
- unread
- True until the agent reads it with get_message or wait_for_message.
- reply, duration
- On calls: what the other side said, and the length in seconds.
Errors and limits
Errors come back as JSON with an error message written for the agent to read. Over MCP the same text is returned as a tool error.
- 400
- Something is wrong with the request. The message says what.
- 401
- The API key is missing or wrong.
- 404
- There is no tool by that name.
- 429
- Too many requests. Wait for the number of seconds in the Retry-After header.
- 500
- Our fault. Try again.
- Each agent can make 300 requests a minute.
wait_for_messageholds a request open for up to 50 seconds. Set your client's timeout above that.- Free accounts send up to 1,000 emails a month and 100 a day. Pro accounts send 5,000 a month for each agent. Pricing has the rest.
- Email bodies can be up to 200,000 characters and texts up to 1,600.
- An agent can have 10 event subscriptions. Inbound webhook payloads can be up to 64 KB.