One request is one telephone call, in your user's name. What comes back is a written note and one of four words. Never the audio, never a transcript.
A phone number is the account. Open api.sakhai.care/signup, or do it by hand:
POST https://api.sakhai.care/api/v1/signup
{"phone": "+14155550123"}
→ 200 {"ok": true, "to": "****0123", "expires_in": 600}
POST https://api.sakhai.care/api/v1/signup/verify
{"phone": "+14155550123", "code": "482913",
"label": "my assistant", "webhook": "https://you.example/sakhai"}
→ 200 {"key": "sk_sakhai_…", "secret": "…", "name": "t-3f9a1c", "rotated": false}
The key and the signing secret are shown once. The same number again mints a new key and the old one dies — that is how a key is rotated, and how a lost one is recovered. Three codes an hour per number; a code lives ten minutes and five tries.
https://api.sakhai.care/api/v1 Authorization: Bearer sk_sakhai_… Content-Type: application/json
Everything is JSON. The spec is openapi.json (OpenAPI 3.1); paste it into any client generator, or into an assistant that takes a spec.
GET /tasks returns this catalogue as JSON. Each task is one errand; needs are refused missing; what she asks is roughly how she puts it on the line, and ask is the question you put to your user before you send consent: true — filled in with what they told you.
| task | kind | needs | optional | what she asks | the question you put to your user (ask) |
|---|---|---|---|---|---|
book_table | arrange | date, time, party | notes | "a table for {party} on {date} at {time}" | "Shall I ring {business} and book a table for {party} on {date} at {time}?" |
order_pickup | arrange | items | time, notes | "an order for collection: {items} at {time}" | "Shall I ring {business} and put in the order — {items}?" |
check_stock | ask | item | item_number, notes | "whether you have {item} in stock in the store right now, and roughly how many; if not, when the next delivery is" | "Shall I ring {business} and ask whether they have {item} in stock right now?" |
check_appointment | ask | date | time, notes | "whether {name}'s appointment on {date} still stands, and at what time" | "Shall I ring {business} and check that {name}'s appointment on {date} still stands?" |
report_absence | arrange | date | student, reason, notes | "to let them know that {name} will not be in on {date} because {reason}" — left on a recorder if one answers, and that counts as completed, unconfirmed | "Shall I ring {business} and report {name} out on {date}?" |
get_quote | ask | what | when, notes | "what they would charge for {what}, and what that includes" | "Shall I ring {business} and ask what they would charge for {what}?" |
ask | ask | question | — | your user's own words, once | "Shall I ring {business} and ask: {question}?" |
arrange | arrange | request | — | your user's own words, once | "Shall I ring {business} and arrange this — {request}?" |
An arrange task needs for.name — whose name the booking goes in. An ask task does not.
Gather first, then read back, then consent. Your user will say something short — "call the school, Arjun's out sick Monday" — and will not know what a call needs. That is your job, in conversation: ask for what the task needs and nothing more, one question at a time (the catalogue lists each task's fields; a 400 missing names what is still empty; a 422 no_number means the business could not be found from its name and town, so ask for the number). Then show the three things — the business, its number, and the errand in one sentence — and end with the task's own question: ask in the catalogue is a template you fill — "Shall I ring California High School and report Amy out on Tuesday, September 29?" — never a form's "do you consent". Their yes to that question is what consent: true means. It is never assumed from the first message, and it is never set by you. If your user gives a number where the business can reach them, send it as for.phone; an attendance line or a recorder will ask for one.
POST /calls
{
"task": "book_table",
"business": "Piatti", ← the name; with "phone", or findable with "town"
"phone": "+19253280525", ← their number, E.164 or local to "country"
"town": "Danville",
"country": "US",
"tz": "America/Los_Angeles", ← your user's zone; their hours are judged in it
"details": {"date": "2026-09-26", "time": "19:00", "party": 2}, ← ISO; she says "Saturday, September 26 at 7 PM"
"for": {"name": "Moses Rajan", "phone": "+14155550123"},
"fallback": "any time between six and eight",
"consent": true, ← required, and it is yours
"webhook": "https://you.example/sakhai",
"idempotency_key": "order-8812"
}
→ 202
{"id": "c_x9Qk3nLp2aBc", "task": "book_table", "status": "calling",
"business": "Piatti", "number": "+19253280525",
"errand": "a table for 2 on 2026-09-26 at 19:00",
"for": {"name": "Moses Rajan", "phone": "+14155550123"},
"outcome": null, "created": 1790316639.0, "ended": null, "webhook_delivered": false}
| field | meaning |
|---|---|
task | one of the eight |
details | the task's fields from the table above, plus patient: {name} when the line will ask who it is for (a school's attendance line, a pickup) — said on the line if asked, never stored. A date of birth is not taken |
business, phone, town, country | a name with a number, or a name findable with a town. A number alone is dialled as given |
tz | your user's time zone. The business's hours, and the eight-to-nine rule, are judged in it |
hours | their hours today if you know them, e.g. 9am–6pm; never overrides eight to nine |
for | {name, phone}: whom she is calling for — the booking's name, the number they may ring back |
fallback | for an arrangement: what to take if the exact thing is not available. Without it, an offer of something else comes back as needs_user |
menu_hint | which key to press on a phone menu, if you know |
consent | must be true: your user saw the business, the number and the errand and said yes to the task's own question (ask, filled in). The read-back lives on your side of the wire |
webhook | where the outcome is POSTed; else the key's own webhook; else you poll |
idempotency_key | the same key returns the same call (200, the object as it stands) rather than a second dial |
GET /calls/c_x9Qk3nLp2aBc the same object, settled or not
GET /calls this key's last fifty, newest first: {"calls": [...]}
status runs queued → calling → one of completed, needs_user, failed. A call settles within a few minutes: talking is capped at four, hold at twenty.
When the call settles, the Call object is POSTed to the call's webhook (else the key's). Three tries — 1, 5 and 25 seconds apart — until you answer with any 2xx. Every POST carries
X-Sakhai-Signature: <unix seconds>.<hex hmac-sha256>
where the HMAC, keyed with your signing secret, is over "<unix seconds>." + the raw body. Verify it, and drop anything more than five minutes old:
import hmac, hashlib, time
def verify(header: str, raw_body: bytes, secret: str) -> bool:
ts, _, mac = header.partition(".")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
want = hmac.new(secret.encode(), ts.encode() + b"." + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(want, mac)
{"id": "c_x9Qk3nLp2aBc", "status": "completed", ...,
"outcome": {
"answer": "seven for two, in the name Rajan", what they said
"price": "", a price, when one was given
"conditions": "fifteen minutes' grace", the small print they said
"booked": true, an arrangement was made
"confirmed": true, …and the other end said it back
"held_s": 75, seconds on hold
"via": "phone",
"answered_by_assistant": false, their side was a machine
"left_message": false, "message": "", a recorder took it (report_absence)
"reason": "" why needs_user or failed, in a phrase
}}
| status | means |
|---|---|
completed | what was asked, done or answered — or, for report_absence, the message left on a recorder (left_message: true, confirmed: false) |
needs_user | the other end offered something else — another time, a card wanted, a question only your user can answer — and she did not decide for them. reason says what |
failed | voicemail, no answer, busy, a phone menu with no way through, out of time, held too long, the line failed, the call could not be placed. reason says which |
A fourth word, wrong result, exists only in our own judged sheet: a person's mark against what the business actually said, never the engine's own. That sheet is where the number on the front page will come from.
Every error is {"error": "<word>", "detail": "<a sentence>"}, with fields when fields are missing and id when a call was opened before the refusal.
| HTTP | error | when |
|---|---|---|
| 400 | bad_task, consent, missing, bad_json | not in the catalogue; consent not true; a needed field empty; not JSON |
| 401 | unauthorized | no valid bearer key |
| 403 | off | the calls API is not on here |
| 404 | not_found | no such call under this key |
| 422 | identity | a line that must look the person up first — see never rung. Nothing dialled; detail is the answer for your user and number / tel carry the line's number |
| 422 | no_number | no number given and none found for the business |
| 422 | bad_number | the number cannot be dialled |
| 422 | closed | they are closed now, or it is not a time to ring (never before 8 or after 21 in your user's zone) |
| 429 | busy | three calls already on the line for this key |
| 502 | not_placed | the call could not be placed |
A pharmacy, a doctor's or dentist's office, a clinic, a hospital, a lab, a vet, an insurer or health plan, a bank or credit union, a tax office, a utility account — anywhere the other end must find your user in its own records first — is refused at submit with 422 identity, and nothing is dialled. She never asks for a date of birth, a member number or a prescription number to try anyway. That is your user's call to make themselves.
The detail of that refusal is a whole answer you can show as it is, and it ends with the line's number — the one you sent, or the one Sakhai looks up on Google Maps by name and town, with the street and today's hours. The same number comes back structured beside it: number (E.164) and tel (a tel: link), so your user can tap it and ring themselves. If you refuse such a line on your own side, still give the person its number — or send the request and Sakhai returns it. Do not collect a date of birth, a member number or a prescription number on your side to try instead; the whole point is that nobody does.
A name for a reservation, a pickup, a stock check or a school's attendance line is a booking name, not a lookup, and stays.
failed with the reason.For Claude, ChatGPT or any MCP host: a single-file server with three tools — make_call, check_call, list_tasks — standard library only, stdio.
{"mcpServers": {"sakhai-calls": {
"command": "python3", "args": ["/path/to/sakhai_calls_mcp.py"],
"env": {"SAKHAI_CALLS_URL": "https://api.sakhai.care/api/v1",
"SAKHAI_CALLS_KEY": "sk_sakhai_…"}}}}
The file is sakhai_calls_mcp.py in the Sakhai server repository; write to support@cogniolab.com for it, or for anything else on this page.