API reference / Personas
Personas
There is no persona table here. A caller has one settings record, it has no id and no name, and every session that credential opens uses it. Two routes read and write it, and the fields it holds are most of what a persona is elsewhere: the prompt, the model, the voice, the avatar, the sampling knobs.
There is no list, no create, no id addressed read, no update by id and no delete, because there is nothing to address: the subject of both routes is the credential the request was already authenticated with, and it is never named in a path, a query parameter or a body field. Building named personas is a new table rather than a field rename, so it is not described here as if it were nearly done.
| Method | Path | Purpose | Auth |
|---|---|---|---|
| GET | /api/settings | This caller's effective settings, plus the catalogues | any |
| POST | /api/settings | Save this caller's settings | full |
Read the record
Takes no parameters. Whose settings these are comes from the credential, never from the request.
curl "https://avatar.zelibot.xyz/api/settings" -H "X-Api-Key: zsk_live_..."const { settings, models, effort_levels, tts_providers, persisted } =
await admin.management.getSettings();{
"settings": {
"provider": "anthropic",
"model": "au.anthropic.claude-sonnet-5",
"live_model": "au.anthropic.claude-haiku-4-5",
"system_prompt": "You are a friendly front desk host.",
"max_tokens": 1024,
"top_p": null,
"temperature": null,
"effort": "medium",
"reasoning": "low",
"tts_provider": "cartesia",
"voice_id": "a0e99841-...",
"voice_id_chatterbox": null,
"avatar": "01-presenter-male",
"emotion_responsive": true
},
"models": [],
"effort_levels": ["low", "medium", "high", "xhigh", "max"],
"reasoning_levels": ["off", "low", "medium", "high"],
"tts_providers": [],
"persisted": true
}| Field | Meaning |
|---|---|
settings | This caller's effective record. The whole of it |
models | The model catalogue, documented under LLMs |
effort_levels | Reasoning effort values the models accept, low to high |
reasoning_levels | How long the model may think before it speaks, off to high |
tts_providers | The voice engines this deployment can run, with their capability flags |
persisted | Whether the settings store is reachable. false means a save will not survive the box |
The payload includes system_prompt, which is why it is scoped to
the caller rather than to the box. It used to answer everybody with whoever had
configured the machine last, which on a shared box meant handing out another
tenant's persona.
Write the record
Requires a full key. Send only the fields you want to change; the rest are left alone. Writes nothing to process state, so a save landing during somebody else's reply cannot change it, and cannot change a reply of your own already in flight.
The settable fields are an allowlist, not "whatever was posted":
The quality model, used for a one shot turn.
The low latency model, used for the always on live conversation.
The persona's instructions. This is the entire knowledge surface; see Knowledge.
Reply length ceiling.
Nucleus sampling. Only accepted by models whose
supports_sampling flag is true.
Sampling temperature. Same capability gate as top_p.
One of effort_levels. max is accepted only by
models flagged effort_max. Used only while
reasoning is unset; once a thinking level is chosen, that
level sets the effort.
How long the model thinks before it speaks: one of
reasoning_levels (off, low,
medium, high). Higher levels think longer before
speaking, so replies start later. An empty string or null is
automatic: each model's own default, shown as
default_reasoning in the model
catalogue (low on Claude Opus 5 and Sonnet 5, which think
unless told otherwise; off elsewhere). A value that is not a
level is treated as automatic rather than refused. The level is chosen for
the live_model; the other model takes it only when it takes a
level the same way, and otherwise runs at its own default. A model with no
thinking control ignores it.
What the avatar says before a tool runs when the model said nothing first,
in this persona's own words: {tool name: {language: line}},
for example {"end_call": {"en": "Bye for now."}}.
Languages are en es fr de it pt hi ja; a line is one sentence
of at most 160 characters, with no angle or square brackets; at most 32
tools. The line is chosen in the
call's current language, then the built in line for that language, and
English only as a last resort. skip_turn cannot have one: it is
silent on purpose. A refused value answers 400.
Which voice engine to speak with.
The default avatar for this caller's sessions.
Whether to infer a delivery tone rather than always speaking neutrally.
The Cartesia voice slot, kept under this name for compatibility. Every other
provider gets voice_id_<provider>, matched by prefix.
This persona's custom tools, at most ten. Written whole: send the full list each time, and an empty list clears it. The definition fields and their limits are on the Tools page.
curl -X POST "https://avatar.zelibot.xyz/api/settings" \
-H "X-Api-Key: zsk_live_..." \
-H "Content-Type: application/json" \
-d '{"system_prompt": "You are a friendly front desk host.", "effort": "medium"}'await admin.management.updateSettings({
system_prompt: "You are a friendly front desk host.",
effort: "medium",
});Response 200:
{ "ok": true, "saved": true, "settings": {} }saved is whether the record reached durable storage. settings is the whole
record after the write, so one round trip is enough.
A refusal is a 400 with ok: false and a reason, not a 500. There are two
reasons, and the message names which one it is:
{
"ok": false,
"error": {
"code": "setting_refused",
"message": "..."
}
}A model that does not exist, or one this deployment will not send data to. The
second is the residency rule: see the selectable flag under
LLMs.
Give the persona tools
A persona's custom tools live on the same record, under tools. Defining one is
a settings write, so it needs a full key and belongs on your server. Answering
one happens wherever the session runs, through the SDK.
// Server side, full key: define the tool.
await admin.management.updateSettings({
tools: [
{
name: "book_a_tour",
description: "Book a tour of the building. Call it once the person has named a day.",
parameters: {
type: "object",
properties: { day: { type: "string" } },
required: ["day"],
},
mode: "await_result",
timeout_seconds: 8,
},
],
});
// Where the session runs: answer it. Register before connect.
client.registerToolCallHandler("book_a_tour", async ({ day }) => {
const booking = await tours.book(String(day));
return { confirmed: true, at: booking.time };
});# Server side, full key: define the tool.
await admin.management.update_settings({
"tools": [
{
"name": "book_a_tour",
"description": "Book a tour of the building. Call it once the person has named a day.",
"parameters": {
"type": "object",
"properties": {"day": {"type": "string"}},
"required": ["day"],
},
"mode": "await_result",
"timeout_seconds": 8,
}
]
})
# Where the session runs: answer it. Register before connect.
@client.tool_call_handler("book_a_tour")
async def book_a_tour(args, call):
booking = await tours.book(args["day"])
return {"confirmed": True, "at": booking.time}The read returns the list under settings.tools. The persona only ever sees the
entries that are enabled and valid. A refused definition answers
400 setting_refused and saves nothing from that request.
Whatever a handler returns is read by a language model, and so is the message
of a ToolCallError it throws. Any other error is kept off the wire
and sent as "The tool failed." Return the smallest answer that does the job, never put a secret in a
tool's arguments, description or result, and authorise inside the handler
rather than trusting the arguments. The full list is under
Tools.
Not served in this group
GET /personas,POST /personas,GET,PUTandDELETEby id. There is one unnamed record per credential, so there is nothing to list and nothing to address.- A public widget configuration read. There is no widget, and no unauthenticated read of anything.
- Nested
avatarandvoiceobjects on the record. Our fields are flat scalars:avataris an id string and the voice is a per provider id slot.