Tel
The telecommunications surface: phone numbers, calls and messages, on whatever carrier the deployment is configured for.
Also for this capability: API · CLI · MCP · SDKs
The telecommunications surface: phone numbers, calls and messages, on whatever carrier the deployment is configured for.
| Base URL | https://api.hanzo.ai |
| Operations | 10 |
| Auth | Authorization: Bearer $HANZO_API_KEY |
Specification
HIP-1069 · Tel — Numbers, Calls and Messages — Draft · read the specification →
/v1/tel is the telecommunications surface: an org searches for and holds phone
numbers, places and ends calls, sends messages, and reads the records of all
three. A call may be handed to an assistant, which is ours.
This HIP specifies the carrier contract that keeps the surface white-label, the
honesty rule about what "sent" means, and the tenancy boundary. The implementation
is hanzoai/cloud apps/tel.
Motivation
A telecom surface acquires a vendor faster than almost anything else: a base URL here, a status string there, and within a release the product cannot terminate on a different network without a rewrite. Numbering is national and regulated, so a brand operating in another jurisdiction may have no choice about which network it terminates on.
The second pressure is the assistant. A carrier that also answers calls with its own voice agent is deciding what our product says. That is not a dependency worth having.
Specification
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119.
The carrier is an interface, and nothing above it names a network
Three verbs — numbers, calls, messages — are the whole contract
(apps/tel/carrier.go:16). Everything else in the package is tenancy, records and
policy on top of them.
No code above that interface may name a carrier or a brand. The concrete carrier is built from configuration at mount, so terminating on a different network in a different jurisdiction is an environment change rather than an edit. Credentials come from the environment, populated from the key store on a cluster; there is no literal credential in the source and there must never be one.
A deployment with no carrier credential falls back to a carrier that answers without a network, whose numbers are in the range reserved for fiction. That is also what the tests run against, so the surface is exercised end to end without placing a call.
Acceptance is not delivery
The carrier answers with an identifier as soon as it has accepted a request. The outcome — connected, delivered, failed — arrives later on the event stream.
A message submission MUST NOT be recorded as delivered because the submission succeeded. A carrier that returns "sent" synchronously is telling you it accepted the request, and recording that as delivery is how a message that never arrived becomes a message the record says arrived.
The door that would receive those events does not exist yet: this package mounts no carrier callback route and consumes no stream, so today a record's status is what the carrier answered synchronously plus the transitions this surface itself makes — a hangup, a release (apps/tel/store.go:163). The rule above is the contract the inbound half lands against, not a description of code that runs.
Assistants are ours
A call handed to an agent is answered by a Hanzo assistant on Hanzo inference: the same models, prompts and tools every other surface uses. The carrier moves the audio and does not decide what is said.
The package reaches the platform's own AI door rather than a model package
(apps/tel/agent.go:24), so an assistant improved for chat is improved for calls
in the same release, and which model answers is the catalog's decision behind that
door rather than a constant in a telecom package.
Tenancy
The org is the value minted from the validated bearer's owner claim (HIP-0026), enforced server-side on every request and never a client-supplied header. Every store query filters on it, so one tenant can neither read nor mutate another's numbers, calls or messages.
The surface is an exact set
Every route is a typed operation, and the served set is pinned as an exact set
rather than a floor, so a route added here fails whether or not anyone remembers
the test (apps/tel/typed_wire_test.go).
There is no untyped-by-design ledger, because nothing on this surface refuses: no raw bytes, no multi-status answer, no verbatim relay. The day one appears it is named with its wire fact and the set still has to match.
The addresses are the operations at /v1/tel (plugin/tel/openapi.json): number
search (/numbers/available), the org's numbers (list, buy, release), calls
(list, place, hang up), messages (list, send), and the per-org /summary
roll-up.
What it owns, charges and emits
The store is one SQL database opened at mount (sqlpool.Open("tel", …),
apps/tel/store.go:21) with three tables — numbers, calls, messages — each
leading its primary key with the org, so isolation is a physical property of the
row rather than a WHERE clause somebody has to remember.
It is metered: the plugin declares Price: cloud.Metered
(plugin/tel/main.go:24) and the meter is apps/tel/meter.go. Three acts are
billed — a number order at the platform's flat provision fee (100 cents,
cloud.DefaultResourceFeeCents), a message at 1 cent, a call placement at
1 cent — each movable by the TEL_FEE_CENTS knobs, with 0 making an act free
and un-gated again. A call is priced per placement, not per minute: the
completion callback goes to the customer's own webhook, so the duration never
reaches this process and a minute is not a unit this binary observes. The
debit lands through the metering plane (cloud.Charge.Debit with a
metering.Usage row): authorized before the carrier is asked, debited only
after the carrier confirms, so failed work is not billed. A deployment on the
stub holds no carrier credential, buys nothing, and is neither gated nor
billed.
It publishes no events on the platform bus, so a customer's webhooks (HIP-1310) receive nothing from it, and it emits nothing to observability beyond the request span every route gets.
The stage is ga — the manifest row declares none, and absent is ga
(HIP-0139 §8). The capability derives from no OSS upstream: the carrier is
reached over its HTTP API behind the interface (apps/tel/rest.go), configured
by TEL_CARRIER_BASE and TEL_CARRIER_KEY, and the fallback carrier is this
package's own stub.
Rationale
The alternative is to build against one carrier's API and generalize later. Measured against how this surface actually changes — a brand, a jurisdiction, a regulator — later is exactly when generalizing is most expensive, because by then the vendor's vocabulary has leaked into the records, the status values and the console. Three verbs behind an interface cost one indirection now.
Security Considerations
The tenant boundary is server-side on every request and derives from a validated credential rather than an asserted header, because the objects behind it are billable and externally visible: a number bought under another tenant's org is a charge and a phone line.
Carrier credentials live in configuration sourced from the key store and never appear in a response. Records carry what the carrier reported: with no event door yet, a status stays at what the carrier acknowledged, so the record cannot claim an outcome nobody observed.
Four surfaces
| Surface | Reaches this capability as | Coverage |
|---|---|---|
| REST | tel at its own prefix | 10 operations |
| CLI | hanzo tel … | 10 of 10 |
| SDK | TelApi in every published client | 10 methods |
| MCP | tool tel on https://api.hanzo.ai/v1/mcp | 10 operations, 1 under the document's own id — ask describe for the rest |
Quickstart
export HANZO_API_KEY=sk-... # console.hanzo.ai → API keysThen the first call — a read that needs nothing but the key. GET /v1/tel/calls, operation get_tel_calls:
hanzo tel calls listimport { Configuration, TelApi } from 'hanzoai';
const api = new TelApi(new Configuration({ accessToken: process.env.HANZO_API_KEY }));
const { data } = await api.getTelCalls();from hanzoai.cloud import ApiClient, Configuration
from hanzoai.cloud.api import TelApi
client = ApiClient(Configuration(access_token=os.environ["HANZO_API_KEY"]))
result = TelApi(client).get_tel_calls()cfg := cloud.NewConfiguration()
cfg.AddDefaultHeader("Authorization", "Bearer "+os.Getenv("HANZO_API_KEY"))
client := cloud.NewAPIClient(cfg)
resp, _, err := client.TelAPI.GetTelCalls(context.Background()).Execute()
if err != nil {
return err
}use hanzo_cloud::apis::{configuration::Configuration, tel_api};
let mut cfg = Configuration::new();
cfg.bearer_access_token = std::env::var("HANZO_API_KEY").ok();
let result = tel_api::get_tel_calls(&cfg, Default::default()).await?;import ai.hanzo.cloud.ApiClient;
import ai.hanzo.cloud.api.TelApi;
ApiClient client = new ApiClient();
client.setRequestInterceptor(b -> b.header("Authorization", "Bearer " + System.getenv("HANZO_API_KEY")));
var result = new TelApi(client).getTelCalls();curl https://api.hanzo.ai/v1/tel/calls \
-H "Authorization: Bearer $HANZO_API_KEY"MCP reaches tel through the tel tool, which names its 10 operations with its own verbs — this one among them, under a name only MCP declares. describe explains any of them:
curl -X POST https://api.hanzo.ai/v1/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "describe",
"arguments": {
"op": "list_tel_calls"
}
}
}'Answers 200 with object — ok.
Endpoints
| Endpoint | What it does |
|---|---|
DELETE /v1/tel/calls/{id} | Ends a call this org placed. |
GET /v1/tel/calls | Lists the calls this org has placed or received, newest first. |
POST /v1/tel/calls | Dials. |
GET /v1/tel/messages | Lists the messages this org has sent or received, newest first. |
POST /v1/tel/messages | Sends a message from one of this org's own numbers. |
DELETE /v1/tel/numbers/{id} | Checks the holding is THIS org's before it reaches the carrier. |
GET /v1/tel/numbers/available | Asks the carrier what is available to buy. |
GET /v1/tel/numbers | Lists the phone numbers this org HOLDS — the ones it has bought and not released. |
POST /v1/tel/numbers | Provisions with the carrier FIRST and records second. |
GET /v1/tel/summary | Counts what this org holds on the telephony plane: its numbers, its calls and its messages. |
How is this guide?