Most/Docs

Phone calls

Most places phone calls and answers the ones that ring, through Twilio Programmable Voice. A call is a conversation in text: Most speaks what the API gives it, posts what the other side said as a webhook, and records every line. The same operations exist in the HTTP API, most-cli and Most Desktop.

Quickstart: your first call, from a fresh machine#

You need a Twilio account with a voice-capable number, the Rust toolchain, access to the private wisent-ai/most repository, and a way to reach this machine over HTTPS from Twilio. No other Wisent product is required: the bearer lives in a file, calls are kept in memory in the `dev-sms` composition (or in your own PostgreSQL with `MOST_DATABASE_URL` and the `prod` composition), and any HTTPS tunnel works. With Stado and Skarbiec, the same service takes its database from `stado database most` and its Twilio credentials from the `most-twilio` item instead.

Start a tunnel first, because Twilio must reach the service: with Cloudflare's `cloudflared tunnel --url http://$MOST_BIND_ADDR`, where `MOST_BIND_ADDR` is the loopback host:port the service listens on, the `https://…trycloudflare.com` origin it prints is your `PUBLIC_BASE_URL`. Then build and start Most, check readiness, put the three URLs it prints on the Twilio number, and place a call.

From clone to a ringing phone
git clone https://github.com/wisent-ai/most.git && cd most
mkdir -p ~/.config/most && umask 077
openssl rand -hex 32 > ~/.config/most/bearer

# terminal 1: the service
export MOST_BIND_ADDR=<loopback host>:<free port>
MOST_COMPOSITION=dev-sms \
PUBLIC_BASE_URL=https://your-tunnel.trycloudflare.com \
MOST_API_TOKEN_FILE=~/.config/most/bearer \
MOST_TWILIO_ACCOUNT_SID=AC… MOST_TWILIO_AUTH_TOKEN=… MOST_TWILIO_FROM=+48221234567 \
cargo run --release -p most-server --manifest-path most-rs/Cargo.toml

# terminal 2: the CLI
cargo install --path most-rs/crates/most-agent
export MOST_BASE_URL=http://<the address terminal 1 listens on>
export MOST_AGENT_API_TOKEN=$(cat ~/.config/most/bearer)
most-cli calls-readiness --text      # available: true, and the three URLs for the number
most-cli calls place +48601234567 --say "Dzień dobry, tu Most. Proszę powiedzieć coś po sygnale." --language pl-PL
most-cli calls --status in_progress  # the call you just placed
most-cli calls say <call_id> --say "Dziękuję, do usłyszenia."
most-cli calls hangup <call_id>

What people build with it#

  • Appointment confirmations: place a call the day before, say the time, and read the patient's answer from `call.speech`; a reply that is not a clear yes goes to a person.
  • An answering line for an agent: subscribe to `call.ringing`, answer with a greeting, send each `call.speech` text to your model (inside Wisent, through Brama), and speak its reply with `/say` until the caller hangs up.
  • Delivery and on-call alerts that must be heard, not read: call the person on duty, say what broke, and treat `call.ended` with `no_answer` or `busy` as the signal to call the next one.
  • A call log with a transcript for every conversation: `GET /v3/calls/{call_id}` returns every line both sides said, ready for search or a CRM.

How a call works#

The provider does the speech in both directions: Most hands it text, which it speaks, and listens for the other side, whose speech it turns into text and posts back. Most stores that text as a `remote` line, emits `call.speech`, and keeps listening. Your service answers with `POST /v3/calls/{call_id}/say`, which Most speaks and stores as an `agent` line. A call stays open and listening until one side hangs up.

Outbound: `POST /v3/calls` records the call `queued` and asks the provider to dial. When the callee picks up, the provider asks Most what to do; Most speaks the call's `say` text, marks the call `in_progress` and emits `call.answered`. Progress the provider reports moves the call through `ringing` to `completed`, or to `busy`, `no_answer`, `failed` or `canceled`, and emits `call.ended` exactly once.

Inbound: when somebody calls the Twilio number, the provider requests the number's Voice URL. Most records the call `ringing`, emits `call.ringing`, and holds the provider's request open until your service answers (`POST /v3/calls/{call_id}/answer`) or rejects it (`POST /v3/calls/{call_id}/reject`). Most keeps no clock of its own on that wait: when the provider stops waiting it requests the number's Voice Fallback URL, and Most records the call `missed`, emits `call.ended` and turns the caller away with a busy signal.

The number a call comes from is the Twilio number's caller id; no request names a sender. Recognition quality and the voice are the provider's; delivery of a call needs a configured Twilio account with a voice-capable number.

Routes#

  • `GET /v3/calls/readiness` — whether this service can make and take calls. Most asks the voice provider, placing no call: with your own account it reads the Twilio account and requires `MOST_TWILIO_FROM`; through wisent-integrations it sends `most/place-call` an empty body, which wisent-integrations refuses as `bad_request` only after it has authorised Most for that action and read a complete Twilio account (account, token, API version and caller id) from the item tagged `stado:role:most-twilio`; an incomplete or missing item answers HTTP 503. `available` is true only when the provider answered that way; `provider` says what it answered, `reason` names the step that failed, and `number_urls` lists the three URLs the Twilio number must call whenever the service has a call desk.
  • `POST /v3/calls` with `{"to", "say", "language"?}` — dial `to` (E.164) and say `say` when it answers. `language` is a BCP 47 tag such as `pl-PL` for speech in both directions; the provider's default when omitted.
  • `GET /v3/calls?status=&limit=` — calls newest first, optionally one status. Omit `limit` for every matching call; a supplied positive whole number bounds the answer and must fit the database LIMIT.
  • `GET /v3/calls/{call_id}` — one call and its `utterances`, oldest first.
  • `POST /v3/calls/{call_id}/answer` with `{"say", "language"?}` — pick up a ringing inbound call and say `say`.
  • `POST /v3/calls/{call_id}/reject` — decline a ringing inbound call.
  • `POST /v3/calls/{call_id}/say` with `{"text"}` — speak on a call in progress, then listen.
  • `POST /v3/calls/{call_id}/hangup` — end a call in progress, stop one still queued or ringing, or decline a ringing inbound call.
  • Every route takes the same service bearer as `/v3/chats`, and every mutation answers with the call as it now stands.
A call
{
  "id": "7c1d2e3f-…", "direction": "outbound",
  "from": "+48221234567", "to": "+48601234567",
  "status": "in_progress", "language": "pl-PL",
  "provider_call_id": "CA…", "error": null,
  "created_at": "2026-10-01T09:00:00+00:00",
  "answered_at": "2026-10-01T09:00:07+00:00",
  "ended_at": null, "duration_seconds": null,
  "utterances": [
    {"id": "…", "speaker": "agent", "text": "Dzień dobry, tu Most.",
     "confidence": null, "created_at": "2026-10-01T09:00:07+00:00"},
    {"id": "…", "speaker": "remote", "text": "Dzień dobry.",
     "confidence": 0.93, "created_at": "2026-10-01T09:00:10+00:00"}
  ]
}

Statuses#

`queued`, `ringing` and `in_progress` are a call that is not over. `completed`, `busy`, `no_answer`, `failed`, `canceled`, `rejected` and `missed` are final: a final call never moves again and nothing can be said on it. A call the provider refused to dial is still recorded, `failed`, with the provider's answer in `error`, and `POST /v3/calls` answers with it like a failed message receipt.

Webhook events#

Subscribe to `call.ringing`, `call.answered`, `call.speech` and `call.ended` like the message events; `phone_numbers` filters on the other party of the call. Each delivery is signed the same way and carries `data.call` (the call as above, without utterances) and `data.handle.handle`; `call.speech` adds `data.text` and `data.confidence`.

call.speech
{
  "id": "…", "type": "call.speech", "created_at": "2026-10-01T09:00:10+00:00",
  "data": {
    "call": {"id": "7c1d2e3f-…", "direction": "outbound", "status": "in_progress", …},
    "handle": {"handle": "+48601234567"},
    "text": "Dzień dobry.", "confidence": 0.93
  }
}

Configuration#

  • Your own Twilio account: `MOST_TWILIO_ACCOUNT_SID`, `MOST_TWILIO_AUTH_TOKEN` and `MOST_TWILIO_FROM`, the voice-capable number calls come from. Without `MOST_TWILIO_FROM` a placed call is recorded `failed` with `MOST_TWILIO_FROM is not set: a call needs the Twilio number it comes from`.
  • Or the wisent-integrations boundary: `STADO_INTEGRATION_API_URL` and `MOST_STADO_INTEGRATION_TOKEN`, a bearer `INTEGRATION_CLIENTS` allows for `most/place-call`, `most/update-call` and `most/verify-voice-request`. The account, token, `api_version` and caller id (`from_number`) stay on the provider side, in the one vault item tagged `stado:role:most-twilio`: wisent-integrations asks Skarbiec for that role, so an untagged item is not found however complete it is (`stado credentials item retag --host <vault host> <item> --tags stado:role:most-twilio`).
  • `PUBLIC_BASE_URL`: this service's public HTTPS origin. The provider posts call progress and the other side's speech there, and Most checks every such request against the provider's signature over that origin.
  • On the Twilio number, set the three URLs `most-cli calls-readiness` prints, all with POST: Voice URL (`/v3/providers/twilio/voice/incoming`), Voice Fallback URL (`/v3/providers/twilio/voice/fallback`) and Call status changes (`/v3/providers/twilio/voice/status`).
  • Compositions `dev-sms`, `dev-all` and `prod` build the call desk; `dev-sms` and `dev-all` keep calls in memory, `prod` in PostgreSQL (migration `010_calls.sql`).

Refusals#

  • 501 `calls are unavailable: <reason>`, where the reason is one of: `the <composition> composition carries no voice transport; run dev-sms, dev-all or prod`; `no voice transport is declared: set MOST_TWILIO_ACCOUNT_SID, MOST_TWILIO_AUTH_TOKEN and MOST_TWILIO_FROM for your own Twilio account, or STADO_INTEGRATION_API_URL and MOST_STADO_INTEGRATION_TOKEN for the wisent-integrations boundary`; `PUBLIC_BASE_URL is not set: the provider posts call progress and the other side's speech to this service's public origin`; `PUBLIC_BASE_URL must be this service's public HTTPS origin, such as https://most.example.com: the provider calls back only over HTTPS, and the URL carries no user, password, query or fragment`; or the transport's own sentence for a partly declared account, such as `MOST_TWILIO_ACCOUNT_SID is set but MOST_TWILIO_AUTH_TOKEN is not`.
  • 422 for a body the route cannot take: `to must be a phone number in E.164 form: a plus sign followed by digits, such as +48601234567`, `say must not be empty`, `text must not be empty`, `language must be a BCP 47 tag such as pl-PL: letters and digits in parts joined by hyphens`, or `invalid request body: …` for malformed JSON or an unknown field.
  • 400 on the list: `status must be one of: queued, ringing, in_progress, completed, busy, no_answer, failed, canceled, rejected, missed` and `limit must be a positive whole number representable by the database LIMIT`.
  • 404 `call <id> does not exist` for an unknown call id.
  • 409 for a call in the wrong state: `call <id> is <status>; only a ringing inbound call can be answered`, `… can be rejected`, `call <id> is <status>; only a call in progress can be spoken on`, `call <id> is already over (<status>)`, `call <id> has no provider call yet; hang up again once it is ringing`, and `call <id> is no longer waiting on this service for an answer: the provider stopped waiting or the service restarted` — that call is then recorded `missed`.
  • 502 when the provider refused: `the provider refused to speak on call <id>: <the provider's answer>` and `the provider refused to hang up call <id>: …`.
  • On the provider's routes: 403 `the request does not carry a valid provider signature`, and 502 `the provider signature could not be checked: …` when the signature check itself could not run.
  • Readiness answers 200 with `available: false` and `reason: the voice provider is not usable: <step>`, where the step is `MOST_TWILIO_FROM is not set: a call needs the Twilio number it comes from`, `GET Twilio account answered HTTP <status>: MOST_TWILIO_ACCOUNT_SID and MOST_TWILIO_AUTH_TOKEN do not open an account`, `MOST_TWILIO_FROM <number> is not a number of this Twilio account`, `MOST_TWILIO_FROM <number> cannot carry voice calls: Twilio lists it without the voice capability`, `GET Twilio IncomingPhoneNumbers answered HTTP <status>`, `POST <origin>api/integration/most/place-call answered HTTP 503 Service Unavailable: wisent-integrations cannot use the vault item tagged stado:role:<role> (field <field>, cause <cause>)` — the cause is `role_not_granted`, `vault_read_failed`, `field_empty` or `field_malformed`, so the reason names the field to fill —, `POST <origin>api/integration/most/place-call answered HTTP <status>: MOST_STADO_INTEGRATION_TOKEN is not accepted for most/place-call; INTEGRATION_CLIENTS must allow that action for Most's token item`, `… this wisent-integrations has no most/place-call action; deploy a release that carries it`, or the request that did not answer. With the user's own account, an available answer's `provider` names the number and the Voice URL Twilio has for it, or says no Voice URL is set, so incoming calls do not reach Most yet.
  • A placed call Twilio answered without a call sid, status or caller id is recorded `failed` with `place call: Twilio answered call <sid> without …`; a call Twilio later reports `failed` carries `Twilio error <code>: <message>`, or `Twilio reported CallStatus=failed without an ErrorCode or ErrorMessage` when Twilio gave neither.
  • A successful voice-boundary envelope must carry a `result` object. Missing, null or scalar results refuse with operation, endpoint, HTTP status and `missing result object` or `result is not an object`; update/end-call operations do not treat an absent result as an acknowledgement.
  • An update acknowledgement must name the requested `call_id` and a recognized Twilio status. Missing or mismatched identifiers and absent or unknown statuses are refused; accepting a result object alone does not prove that the intended call changed.
  • Direct Twilio updates likewise require the returned `sid` to match the requested call and `status` to be recognized before acknowledging redirect or end requests.
  • Ending a call additionally requires the acknowledgement to report the requested `completed` or `canceled` state, on both provider routes. A different observed state is refused with the requested and returned statuses; a recognized but still-active state does not cause Most to mark the call finished.

CLI, MCP and desktop#

`most-cli calls-readiness`, `most-cli calls [--status <status>] [--limit <n>]` and `most-cli call <call_id>` read; the MCP tools `most_calls_readiness`, `most_calls` and `most_call` expose the same reads to an agent. `most-cli calls place <to> --say <text> [--language <tag>]`, `calls answer <call_id> --say <text> [--language <tag>]`, `calls reject <call_id>`, `calls say <call_id> --say <text>` and `calls hangup <call_id>` act on calls and exist only in the CLI: MCP stays read-only, so an agent reading Most cannot place a call. A placed call the provider did not take prints the call, then `error: the call failed: <cause>`, and exits 1.

Most Desktop's Calls screen shows readiness with the three number URLs or the reason calls are unavailable, places a call after a confirmation that lists the whole text, lists calls, shows the selected call with every line said, answers or rejects a ringing call, and speaks on or hangs up a call in progress. Refusals appear in the service's own words; a call id that is not a UUID is refused before any request.

The real direct-account qualification runner is node tests/calls/place-and-answer.mjs, with all prerequisites named in that file. It places and answers a real call, speaks on the live call, reads back the utterance and both terminal legs, and attempts to end every run-owned recorded call before stopping its isolated server. CLI commands run asynchronously so the event receiver remains responsive; event waits fail on server exit or an early terminal call event, and receiver bind failures reject setup. Cleanup refusals prevent a passed result; this runner is not proof of an integration-boundary or desktop journey.