Assistants
Take bookings by phone
Build an assistant that answers a real number, collects booking details into a capture form, and hands the caller to a person the moment they ask for one.
By the end of this you will have a number customers can dial, an assistant that walks them through your booking questions and delivers the answers to your front desk, and a rule that puts a real person on the line whenever the caller asks for one.
It is all REST. Only the second-to-last step needs a server of your own, and you can skip that one and read the results back over the API instead.
Overview#
Four resources do the work, and they are created in this order because each one references the one before it.
| Resource | What it contributes |
|---|---|
| Assistant | The voice, the model, and the instructions that make it sound like your front desk |
| Capture form | The booking questions, asked in order, delivered to the people you name |
| Phone number | The number customers actually dial |
| Transfer reason | The rule that decides when the assistant should stop handling the call itself, and where the caller goes instead |
Two of those are worth separating in your head before you start. A capture form collects structured answers and sends them to your team while the assistant stays on the call. A transfer reason ends the assistant's part in the conversation and connects a person. A bookings assistant usually wants both: the form for the ordinary booking, the transfer for everything unusual.
Prerequisites#
- A tenant API key, sent as
Authorization: ApiKey YOUR_API_KEYon every request. See Authentication. - An email address that should receive completed booking details.
- A phone number a person actually answers, in E.164 (
+15555550123), for the hand-off. - A phone to dial with in the last step.
Create the bookings assistant#
Every assistant references a voice, a language model and a transcriber by ID. Quickstart walks through listing all three catalogues; if you already have the IDs, use them here.
curl -X POST https://core-api.heysadie.ai/assistants \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Bookings",
"voiceId": "VOICE_ID",
"modelId": "MODEL_ID",
"transcriberId": "TRANSCRIBER_ID",
"firstMessage": "Riverside Bistro, this is Ava. Would you like to book a table?",
"endOfCallMessage": "Thanks for calling. See you soon!",
"developerPrompt": "You take table bookings for Riverside Bistro. We are open {{opening_hours}}. Take one booking per call. Never confirm a booking you have not collected every detail for, and never promise a table outside opening hours.",
"parameters": { "opening_hours": "midday to 10pm, Tuesday to Sunday" },
"categories": ["booking", "enquiry", "complaint"],
"recordingEnabled": true,
"toolIds": [],
"timezone": "America/New_York",
"address": "123 River Street, Springfield",
"phoneNumberPassThrough": true
}'Three fields carry most of the behaviour. developerPrompt is your section of the instructions; parameters fills in its {{placeholders}}, so the opening hours live in one place instead of being retyped into the prompt every time they change. categories are the labels each call gets classified under, which is what makes "how many booking calls did we take last week" answerable afterwards.
The response returns the whole assistant object. Keep data.id (it is ASSISTANT_ID everywhere below) and notice that phoneNumberId comes back null. Nobody can reach this assistant yet.
Collect the booking details with a capture form#
A capture form is an ordered set of questions plus a trigger telling the assistant when they apply. Write the trigger as the caller's situation, not as a note to yourself: "the caller wants to book a table" is something the model can match a conversation against.
curl -X POST https://core-api.heysadie.ai/capture-forms \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"assistantId": "ASSISTANT_ID",
"name": "Table booking",
"trigger": "The caller wants to book a table, or change a booking they already have.",
"introMessage": "Happy to book that in. I just need a few details.",
"emailRecipients": ["[email protected]"],
"fields": [
{
"key": "caller_name",
"question": "What name should I put the booking under?",
"type": "string",
"required": true
},
{
"key": "party_size",
"question": "How many people will be dining?",
"type": "number",
"required": true
},
{
"key": "booking_date",
"question": "Which date would you like?",
"type": "string",
"required": true
},
{
"key": "booking_time",
"question": "And what time?",
"type": "string",
"required": true
},
{
"key": "contact_number",
"question": "What is the best number to reach you on?",
"type": "string",
"required": true
},
{
"key": "notes",
"question": "Is there anything we should know about, like allergies, a high chair, or a celebration?",
"type": "string"
}
],
"isActive": true
}'Field keys are lowercase snake_case and unique within the form; a duplicate key is rejected. type is string or number, and required: true means the assistant asks again rather than letting an answer go missing. The one field left optional here is notes, because an optional question the caller skips is simply left out of the delivery.
Answers arrive during the call
A completed form is delivered to its recipients as soon as the assistant finishes collecting it, not when the call ends. Every form needs at least one recipient (an email address, a text-capable number, or both), and a create with neither is rejected.
The response is the stored form: its id, your fields in the order you sent them, isActive, and the recipients. Capture forms covers the rest of the field options, including options for restricting an answer to a fixed list of choices such as sitting times.
Give it a number to answer#
Search the provider inventory first. Only a number returned by that search can be purchased.
curl "https://core-api.heysadie.ai/phone-numbers/available?country=US" \
-H "Authorization: ApiKey YOUR_API_KEY"
curl -X POST https://core-api.heysadie.ai/phone-numbers \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"phoneNumber": "+12125550123",
"name": "Bookings line"
}'The purchase response carries the number's id, phoneNumber, country, monthlySubscriptionCost and assistantName. The last of those is still null, because owning a number and answering calls on it are two different things. Keep data.id as PHONE_NUMBER_ID.
The link between the two lives on the assistant, not on the number:
curl -X PATCH https://core-api.heysadie.ai/assistants/ASSISTANT_ID \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "phoneNumberId": "PHONE_NUMBER_ID" }'The response echoes the assistant with phoneNumberId set. From this moment, calls to that number are answered by your assistant. The number is billed monthly until you release it with DELETE /phone-numbers/{id}. See Phone numbers.
Hand the caller to a person when they ask#
A transfer reason is one rule: this situation goes to that number. Add as many as you have scenarios; this one covers the caller who wants a human, whatever the topic.
curl -X POST https://core-api.heysadie.ai/transfer-reasons \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"assistantId": "ASSISTANT_ID",
"name": "Caller wants a person",
"reason": "The caller asks to speak to a person, or has a request the assistant cannot complete: a large group, a private hire, a complaint about a previous visit.",
"message": "Of course. Let me put you through to the front desk.",
"phoneNumber": "+15555550123",
"isActive": true,
"waitForResponse": true
}'reason is what the model matches the conversation against, so it is worth writing as generously as the example above: name the situations, not the category. message is spoken before the hand-off, and waitForResponse (default true) has the assistant wait for the caller before transferring. Later, PATCHing isActive: false retires a rule without losing its wording or its destination.
Keep the returned id. It comes back as transfer_reason_id on the end-of-call report whenever this rule fires, which is how you tell an escalated call from an ordinary one.
Pick the mode deliberately for an unstaffed line
How the hand-off sounds is a separate setting: transferMode on the assistant. It takes
cold-transfer, smart-summary, or warm-return.
A cold transfer connects the caller and leaves. If the front desk does not pick up, the
call has already left OnCore and rings out. warm-return brings the caller back to the
assistant instead, which is the right choice for a line that is not always staffed.
Anything other than those three values is rejected with a 400, so a plausible-looking
"warm" or "warm-transfer" is an error rather than a silent cold transfer.
Transfers covers all three modes and that failure case.
Send the results to your own server#
The assistant's serverUrl overrides your tenant-level webhook URL for this assistant only, which is what you want while you are still building.
curl -X PATCH https://core-api.heysadie.ai/assistants/ASSISTANT_ID \
-H "Authorization: ApiKey YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "serverUrl": "https://YOUR-TUNNEL.ngrok.app/oncore/webhooks" }'Receive call results on your server builds the receiver that URL points at, verification included. Skip this step if you would rather poll; the next step shows both routes.
Call it, then read what happened#
Dial your new number. You should hear the firstMessage in the voice you picked. Ask to book a table for four; answer the questions the assistant asks. When it has them all, the booking should land in the inbox you named in step 2, while you are still on the phone. Then say "actually, can I speak to someone?" and you should hear your transfer message before the front desk rings. Hang up.
The end-of-call report arrives at your serverUrl shortly afterwards. These are the fields that tell you how the call went:
| Field | What it tells you |
|---|---|
transcript / summary | The conversation, and the platform's summary of it |
category / category_list | Which of the categories from step 1 the call was classified under |
transfer_reason_id | Set whenever a transfer was attempted; compare it with the id from step 4 |
supervisor_answered | true if the transferred call was answered, false if nobody picked up, null if there was no transfer |
Without a server, pull the same call over the API instead:
curl "https://core-api.heysadie.ai/calls?assistantId=ASSISTANT_ID&limit=1" \
-H "Authorization: ApiKey YOUR_API_KEY"data.calls[0] carries status, endedReason, callDuration, transcript, summary and supervisorAnswered, the REST API's camelCase counterpart of the report's snake_case. transcript and summary are produced after the call ends, so if they are still null, wait a moment and run the request again.
No public structured-answer read API
Capture-form answers are not exposed by the public Core API, the call record, or the end-of-call report. Do not look for them under analysis or extra. The supported outputs are the form's configured email/SMS recipients and the call transcript. If your application must receive structured answers programmatically, use a custom tool that posts them to your backend instead of relying on a capture form.
What you have#
A number that answers, an assistant that takes a booking properly, a paper trail in your inbox, and an escape hatch to a person. Two things to firm up before customers see it:
- Read your own transcripts for a week. Every question the assistant had to ask twice is a
questionworth rewording, and every "I am not sure" is either a missing document or a missing transfer reason. - Decide what happens when the front desk misses the hand-off.
supervisor_answered: falsemeans a caller asked for a person and did not reach one. That is the one outcome worth alerting on.
Next steps#
- Capture forms: the full field model, recipients, and how to switch a form off without deleting it.
- Transfers: cold, warm and warm-return, and what each one sounds like to the caller.
- Answer questions from your documents: the other half of a front-desk assistant.
- End-of-call report: every field on the payload this page reads.