# Voice: the AI operator on a phone call

An operator that places real phone calls for your agent: a sentence of
what to get done, our brain, voice and ears on the line, the transcript
and the outcome back. Metered per minute from the prepaid balance, one
price all in; a call never runs past what it reserved. From the house
number with nothing but your account, from a number you hold, or at the
payphone for one x402 payment. This page is the voice slice of the
contract; the whole of it, every product on one page:
`GET https://api.agentgates.ai/api/account/docs`.

## The operator

The AI operator places a real phone call for you: a sentence of what to
get done (`task`), an optional persona (a name, its manners, one of the
`voices` the catalog's `operator` block lists, a speed inside (0.5, 2)),
our brain on the line or yours over a `webhook`, an optional opening
line (`greeting`, up to 300 characters). The call is shaped by a
playbook matched from the task (`GET https://api.agentgates.ai/api/playbooks`:
order, reserve, appointment, quote, chase, dispute, cancel, verify, a
message left…); the operator opens with who it is and who for, asks one
thing at a time, reads back what it acts on, never invents, never gives
a card number or a PIN, says it is an AI when asked, and writes an
OUTCOME line at the end (the transcript's last entry, `role: outcome`).

ONE price a minute, all in: the catalog's `operator.minuteUsd` is the
line, the brain, the voice and the ears together; a call runs at most
`operator.maxMinutes` (10) minutes, reserves `minuteUsd × minutes`
from your prepaid balance before it dials, bills whole minutes off the
carrier's clock when it hangs up and releases the rest. `minutes` in the
body is the window you want (1 to `operator.maxMinutes`, the ceiling when
you name none): a two-minute errand holds two minutes, not ten, and the
call is cut off there. A call never answered bills nothing, and a call
never runs past what it reserved.

Three doors, one body `{"to" (E.164), "task", "persona"?: "<persona id
you own>" | {"name","instructions"?,"voice"?,"speed"?}, "voice"?,
"brain"?: "ours"|"yours", "webhook"?, "greeting"?}`, every one answering
`201 {"callId","call","perMinuteUsd"}`:

- `POST https://api.agentgates.ai/api/calls` owner-signed: THE HOUSE CALL. No
  number of your own: the call goes out from the house number, metered
  from your balance (the catalog's `operator.house` says whether a house
  number stands right now). `GET` the same
  path for your house calls, newest first; `GET https://api.agentgates.ai/api/calls/{cid}`
  for one (status, seconds, the draw so far as `billedUsd`, the
  transcript and the recording url once it ends). The answer says
  `"from": "house"`.
- `POST https://api.agentgates.ai/api/phones/{id}/calls` owner-signed: the same
  call from a number you hold (the phone plan), the number's own answer
  persona when it has one. `GET .../calls` and `GET .../calls/{cid}`
  read them.
- `POST https://api.agentgates.ai/api/booth` with `{"kind":"call","minutes",
  "to","task","persona"?,"greeting"?}`: THE PAYPHONE, one x402 payment
  buys a window of minutes upfront (the catalog's
  `booth.call.windows[].operatorPriceUsd`), no account, no balance; the
  SAME `X-PAYMENT` with `{"kind":"call","read":true}` reads the call.

Refusals, in the order they are met, nothing charged on any: 503
`calls_dark` (the operator is off), 503 `pool_empty` (house door only),
503 `calls_unpriced`, 400 `bad_request` (the sentence names the field),
402 `balance_empty` (the sentence names the ceiling and what is
available: top up, then call again), 503 `operator_waking` (the
operator's CVM was asleep and is starting: try again in about two
minutes; `retry-after` carries the seconds), 502 `call_failed` (the
carrier or the service refused; the sentence says which).

A number that ANSWERS: `POST https://api.agentgates.ai/api/phones/{id}/answer`
`{"persona","brain","webhook"?,"role"?}` puts the operator on the number's
inbound calls as one of the playbooks' inbound roles (receptionist,
screener, order-taker, reservationist, after-hours, callback-line,
hotline, answering-voicemail); an answered call meters the same minute
from the balance, and a short balance does not answer.

Your page hears the call: `call.changed {"callId","status","what"}` on
your account's own channel (the ticket rides `GET /api/calls` and
`GET /api/account/balance` as `notify`), when the far side picks up,
when the call ends, when the transcript lands.

The CLI: `curl -fsSL https://api.agentgates.ai/api/install | sh`, then
`agentgates call +14155550142 "ask if they have a table for two at 8"`
(`--wait` prints the transcript and the outcome; `--from <phone id>`
picks one of your numbers, `--house` forces the house number,
`--greeting`, `--voice`, `--brain yours --webhook <url>`), and
`agentgates calls [id]` lists and reads. On MCP (mcp.agentgates.ai) the
`call` and `call_read` tools do the same with a voice key (`agv_…`)
passed on each request, never stored. `agentgates phone call` is the
same verb.

## The prepaid balance

`POST https://api.agentgates.ai/api/account/balance` with `{"amountUsd": <rung>}` tops
up a prepaid balance over one x402 settle (rungs today: $1, $5, $25, $100).
Texts, wait-code and call minutes draw from it with no chain write;
an empty balance refuses and nothing is served on IOU. Your balance never
expires and is not refundable. `GET` the same path owner-signed for the
balance and its ledger.

Or fund it from anywhere: `GET https://api.agentgates.ai/api/account/deposit` owner-signed
answers an address that is yours alone (a keyless contract derived from your
signing address, the same address on base or polygon or arbitrum). USDC sent there is credited to
your balance after 3 confirmations, then swept to the treasury; nothing
else can ever leave it. `POST` the same path with `{"scan": true}` to have a
fresh arrival credited now instead of within five minutes.

## Your account

Your account is a key. There is no signup, no session and no token: twelve
words (BIP-39, key at `m/44'/60'/0'/0/0`) sign every call, hold the
prepaid balance, own the browser tasks and lock the vault. Whoever holds
the words is the account, on any machine.

Onboard yourself: `curl -fsSL https://api.agentgates.ai/api/install | sh`, then
`agentgates init`. It mints the words once and prints them with what to
tell your human. `agentgates init --seed "<twelve words>"` brings words a
human gave you; `AGENTGATES_PASSWORDS_SEED` is the same with no file.
`agentgates browser balance` proves you are in and prints the address that
tops the balance up.

Your login key (Agent access): the words never travel. `agentgates init`
mints a login key the moment the account exists, `agk_…`, shown once,
and sends it as `Authorization: Bearer agk_…` on every door below
instead of a signature. Mint another by name or revoke one with the
words: `POST https://api.agentgates.ai/api/account/keys` owner-signed
(`{"name":"…"}` mints, `{"revoke":"<id>"}` revokes), `GET` lists them
with their last use; `agentgates keys` is the same. A revoked key
answers 401 `bad_api_key`; the keys door itself takes the signature
only.

Keys per section: the SCHEME is the scope. `{"name":"…","scope":"voice"}`
mints `agv_…`, a VOICE key. It places and reads calls, and reads what
they cost (your balance, your usage, the catalog and these docs); every
other door answers 403 `key_scope` and names what the key does open.

THE GRID (2026-09-18): a key is minted with `grants`, one entry per
section and verb, `phone|mail|logins|browser|balance|memory` × `read|write`.
Read opens the section's GET doors; Write its POST doors and implies Read.
The account reads (balance, usage, catalog, docs) open for every key; the
keys door and the seal doors take the signature only. POST /api/account/keys
{"name":"…","grants":["phone:read","mail:write"]}; no grants = full access,
the login key as it always was. The list carries each key's grants. A key
that opens Memory may be narrowed to chosen folders: `memoryFolders` at the
mint, or `POST https://api.agentgates.ai/api/account/keys/<id>/memory` after it.
`agk_…` is unchanged and opens everything the words open, which is what
an absent `scope` still mints. Hand an agent that only has to ring
somebody the voice key, so a leak never reaches your saved logins.

A key is TWO PARTS IN ONE STRING (2026-09-19):
`agk_<64 hex>.<64 hex>`. The part BEFORE the dot is the bearer and the
only half that ever reaches us: send that one, `Authorization: Bearer
agk_<64 hex>`. The part AFTER it is the key's reading part, made in your
browser at the mint, and it opens the key's copy of the account's data
key; it never goes on the wire, and a bearer carrying it answers 401
`key_reading_part` without being hashed or looked up. `agentgates
login <key>` takes the whole string and splits it for you. A key minted
before that date is one part and keeps working as it did.

Keys decrypt (2026-09-18): a key whose grid reads Phone or Mail holds
its own copy of the account's data key, so it opens sealed calls,
transcripts, recordings and mail on its own. The copy is made at the
mint on Agent access (the page seals the data key to a keypair derived
from the key's reading part) and stored beside the key row; `agentgates keys
use agk_…` runs the CLI on that key with no words on the machine, and
`agentgates calls` / `agentgates email read` open what they fetch.
`GET https://api.agentgates.ai/api/account/keys/me/wrap` (Bearer, the key's own)
answers the copy; a key without one hears 404 `no_wrap` and reads
clear rows only. Revoking the key ends the door and drops the copy. The
list marks such a key `sealed: true`.

What your keys spent: `GET https://api.agentgates.ai/api/account/usage`, the signature
or a login key, `?from=2026-09-01&to=2026-10-01` (dates or ISO
instants, `to` exclusive, this UTC month by default). It answers the
window's total, `byKey` (every key the account has held, with its own
`byLane` breakdown) and `byLane` across the account. Draws the
ledger cannot name a key for, an owner-signed call among them, land under
`unattributed` and are never folded into a key's row.

Coming back: there is nothing to log in. The key stays on this machine
(`~/.agentgates/passwords-key.json`, or `AGENTGATES_HOME`) and signs every
call; `agentgates init` again answers "key already here" with your address
and never replaces it unless you say `--replace`. A new machine takes the
words: `agentgates init --seed "<twelve words>"` (`agentgates init
--reveal-seed` prints them where you have them).

Hand the session across on one machine, never through a server:
`agentgates open` opens the browser already logged in as you, for your
human; `agentgates receive` prints a code your human types on the site
(Your agent → Connect an agent on this computer) and makes this CLI their
account. On a different machine, your human pastes the words on the site:
Log in → Restore from a recovery phrase.

## Owner calls

Reads and actions on a booth are wallet-signed, no session. Header
`X-Wallet-Auth: base64({"address","timestamp","nonce","signature"})` where
`signature` is an EIP-191 personal_sign over the exact string
`METHOD\npath\nnonce\ntimestamp`
(e.g. `GET\n/api/phones/abc/sms\n<nonce>\n<unix seconds>`).
A Solana wallet signs the SAME string with its ed25519 key, `signature`
base64 of the raw 64 bytes, `address` base58. Nonces are single-use;
timestamps must be within the server skew. The signature is bound to
`/api/account`; a wallet that can sign for compute or runs cannot read
your numbers with that signature (wallet verb: `wallet_signIdentityAuth`).

- `GET /api/phones`: every booth
  this wallet holds (the recovery read after a crash or restart; ids are
  never gone with your state file).
- `GET /api/phones/{id}/sms` and `POST` `{"to","text"}`.
- `POST /api/phones/{id}/wait-code`: blocks up to 90s for a
  verification code and returns `{"code","from","receivedAt"}`.
- `POST /api/phones/{id}/calls`: the number places a call.
  `{"to","bridgeTo"}` (both E.164) dials YOUR OWN handset (`bridgeTo`)
  first, then the peer, and connects the two: you talk through the leased
  number and the peer sees the leased number. `{"to","streamUrl"}`
  (`wss://`) streams the live call audio both ways (mulaw 8 kHz) to your
  own endpoint: your agent is the voice on the line.
  `{"to","task","persona"?,"voice"?,"brain"?: "ours"|"yours","webhook"?,
  "greeting"?}` puts the AI operator on the call; `persona` is a persona
  id you own OR an inline `{"name","instructions"?,"voice"?,"speed"?}`;
  `greeting` (up to 300 chars) is spoken the instant the call connects.
  The catalog's `operator` block lists the `voices` the line can speak
  and the per-minute price with our brain on the call (`minuteUsd`).
  → `{"callId"}`; `minutes` sets the window (1 to 10,
  the ceiling when you name none) and is what the call reserves and is cut
  off at, billed per minute from the balance off the carrier's clock (a
  bridge is two carrier legs and prices accordingly); the operator's minute is ONE
  price, all in (the catalog's `minuteUsd`: the line, the brain, the voice
  and the ears), and a call never runs past what it paid for.
  A call that is never answered bills nothing.
- `GET /api/phones/{id}/calls/{cid}`: status, seconds, the
  draw so far (`billedUsd`), the transcript, whether a recording exists (`recording`), the grade and your own tap.
  `GET /api/phones/{id}/calls/{cid}/recording` streams the recording
  (owner-signed, never a url); `POST .../{cid}/grade` `{"did":"yes"|"partly"|"no"}`
  is your own answer to "did it do what you asked", kept beside the
  machine's grade (`grades`: a result word, booleans, a score and counts,
  never a word of the call).
- SEALED CALLS, ONE WAY. Every account's calls are sealed to the account's
  own key: the AI on the line seals the transcript (your task inside it)
  and the audio to your data key the moment the call ends and keeps no
  copy it can open; the row's `task` is null and `transcript` is one
  sealed blob (`sealed: true`), the recording route streams ciphertext
  (`x-call-sealed: 1`). The CLI and the page open both with the key your
  phrase or your wallet derives. The key is made on your first open of the
  Voice page or your mail, or by the CLI on its first call; `POST
  /api/calls` on an account with no key yet answers 409 `seal_missing`
  and charges nothing. What we still see: who called whom, for how long,
  what it cost, and the grade. The AI on the line hears the call: OpenAI's
  GPT-Live is the ears and the voice, Claude is the brain through
  OpenRouter; Telnyx carries the audio. GET /api/calls/cvm is the voice
  CVM's attestation: the Intel TDX quote bound to a key derived inside it,
  and the measurement of the code it runs. An agent on its own key opens
  your sealed calls two ways: a GRANT (`agentgates passwords allow --agent
  <it>` wraps your data key to its sealing key; it reads with
  `?owner=<you>` and opens locally) or the CVM DOOR (`POST .../{cid}/read`
  `{"permitId"}`, signed by the agent under a permit that lists `calls`:
  the passwords CVM opens the transcript and answers the words, never the
  audio).
- `POST /api/calls`: THE HOUSE CALL, no number of your own
  needed: the same operator body (`{"to","task","persona"?,"voice"?,
  "brain"?,"webhook"?,"greeting"?}`) goes out from the house number,
  metered from your balance at the same operator minute, the ceiling
  reserved first and released as the call bills (`GET /api/account/docs/voice`
  is the whole of it). `GET /api/calls` lists them,
  `GET /api/calls/{cid}` reads one, `GET .../{cid}/recording` streams
  the recording, `POST .../{cid}/grade` takes your tap, `POST .../{cid}/read`
  is the agent's door to a sealed one.
- `POST /api/phones/{id}/answer` `{"persona","brain","webhook"?,"role"?}`:
  how the number answers inbound calls. `role` is one of the playbooks'
  inbound roles (`GET /api/playbooks`: receptionist, screener,
  order-taker, reservationist, after-hours, callback-line, hotline,
  answering-voicemail) and frames the operator on the call; the persona's
  `instructions` are its brief (the business name, the hours, the menu,
  who gets messages). An answered call meters from the balance at the
  operator minute like a call you place; with the balance short, the
  number does not answer. `"brain":"agent"` puts your agent on the
  calls (the agent must be on, 404 `no_agent`; no persona needed): a
  caller bound on the site (the cell you bound for texts) is heard, what
  they say lands in the agent's thread, the agent's answer is spoken on
  the call or texted to that cell after it; any other caller hears one
  line and the call ends.
- `POST /api/personas` `{"name","voice"?,"instructions"}` and
  `GET`: your personas, attachable to any booth you hold.
- Numbers renew with the phone PLAN (POST /api/account/plans), never
  one by one.

## Boundaries, in code

A booth belongs to the wallet that paid and only that wallet reads or uses
it. Every settlement is screened; sanctioned wallets cannot buy. An expired
booth answers 410 with the renew call. wait-code accepts a custom
`pattern` up to 64 characters with no parentheses.
