Sakhai Calls · Cognio Lab LLC

The docs

API version 1 · this page updated 25 September 2026 · openapi.json

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.

1. A key

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.

2. Base URL and authorization

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.

3. The tasks

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.

taskkindneedsoptionalwhat she asksthe question you put to your user (ask)
book_tablearrangedate, time, partynotes"a table for {party} on {date} at {time}""Shall I ring {business} and book a table for {party} on {date} at {time}?"
order_pickuparrangeitemstime, notes"an order for collection: {items} at {time}""Shall I ring {business} and put in the order — {items}?"
check_stockaskitemitem_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_appointmentaskdatetime, 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_absencearrangedatestudent, 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_quoteaskwhatwhen, notes"what they would charge for {what}, and what that includes""Shall I ring {business} and ask what they would charge for {what}?"
askaskquestion—your user's own words, once"Shall I ring {business} and ask: {question}?"
arrangearrangerequest—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.

4. Place a call

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}
fieldmeaning
taskone of the eight
detailsthe 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, countrya name with a number, or a name findable with a town. A number alone is dialled as given
tzyour user's time zone. The business's hours, and the eight-to-nine rule, are judged in it
hourstheir 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
fallbackfor 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_hintwhich key to press on a phone menu, if you know
consentmust 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
webhookwhere the outcome is POSTed; else the key's own webhook; else you poll
idempotency_keythe same key returns the same call (200, the object as it stands) rather than a second dial

5. Read it back

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.

6. The webhook

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)

7. The outcome

{"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
 }}
statusmeans
completedwhat was asked, done or answered — or, for report_absence, the message left on a recorder (left_message: true, confirmed: false)
needs_userthe 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
failedvoicemail, 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.

8. Errors

Every error is {"error": "<word>", "detail": "<a sentence>"}, with fields when fields are missing and id when a call was opened before the refusal.

HTTPerrorwhen
400bad_task, consent, missing, bad_jsonnot in the catalogue; consent not true; a needed field empty; not JSON
401unauthorizedno valid bearer key
403offthe calls API is not on here
404not_foundno such call under this key
422identitya 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
422no_numberno number given and none found for the business
422bad_numberthe number cannot be dialled
422closedthey are closed now, or it is not a time to ring (never before 8 or after 21 in your user's zone)
429busythree calls already on the line for this key
502not_placedthe call could not be placed

9. What is never rung

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.

10. Limits

11. As an MCP server

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.