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

  1. 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.
  2. Open its Connect tab and copy the command there. It already contains the agent's API key.
  3. Paste it into your agent. For Claude Code that is one line in a terminal.
  4. Ask the agent “what is your email address?” It will call whoami and 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.

Payload
{
  "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.com

Phone 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.

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"
}
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_message holds 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.