A session is a GPU seat plus a frozen snapshot of everything the avatar will run with. Its life is: mint → connect → talk → release.
POST /api/v1/sessions ──▶ 201 ready ──▶ browser connects to connect_url
│ │
├──▶ 202 queued ──(poll)──┘ ├──▶ heartbeat (optional)
│ └──▶ release / hang up
└──▶ 503 fleet_busy
POST /api/v1/sessions, secret key, content-type: application/json.
Selecting the avatar — exactly one, see doc 2:
| Field | Type | Notes |
|---|---|---|
avatar_id |
string | The avatar; resolves to its current published version |
avatar_ref |
string | A version id |
avatar_version_id |
string | Explicit version pin |
persona |
string | Stock catalog name |
voice_ref |
string | Optional voice version override; must belong to your workspace |
Behaviour — see doc 4 and doc 5:
| Field | Type | Cap |
|---|---|---|
profile_id |
string | Pin the text layer to one profile |
context.system_prompt |
string | 40,960 chars — replaces the behaviour prompt |
context.backstory |
string | 16,384 chars — replaces the backstory |
context.memory_block |
string | 4,096 chars |
context.history |
array | ≤ 40 messages, ≤ 16,384 bytes |
extra_system_prompt |
string | 2,048 chars — appended, not replacing |
extra_backstory |
string | 2,048 chars — appended (combined extras ≤ 3,072) |
tools |
object | Replaces stored tool config |
extra_tools |
object | Merges into whatever tools resolved |
config |
object | Options group, ≤ 1,024 bytes serialized |
Session shape:
| Field | Type | Notes |
|---|---|---|
response_language |
string | BCP-47 tag ("en") or display name ("English") |
session_cap_seconds |
integer | 60–1200. Box-enforced hard stop. Secret keys only |
observability |
boolean | Default true. false records nothing for this session |
domain_allowlist |
string[] | Origins the edge will accept the connection from |
metadata |
object | Passed through for your own bookkeeping |
queue_ticket |
string | Only when polling a queued mint — see below |
201 ready{
"status": "ready",
"session_id": "019fe497-ac9d-7000-804e-3bad9c970397",
"connect_url": "https://singapore-rtx6000-1.casola.ai",
"session_token": "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9…",
"seat_token": "3f21c8ae-9d44-4c1e-9b7a-2e5f0d6c8a13",
"expires_at": 1786246631,
"cap_seconds": 300,
"profile": {
"id": "019fe463-fc2a-7000-8093-1f0b2ac6e8d1",
"hash_short": "a2271f20509d",
"source": "default"
},
"tools_resolved": ["lookup_order"]
}
| Field | Meaning |
|---|---|
connect_url |
The GPU edge assigned to this session. Different per mint — never cache it |
session_token |
Short-lived EdDSA JWT; the browser’s connect ticket |
seat_token |
Capacity handle for heartbeat/release. Server-side only |
expires_at |
Unix seconds when session_token stops being accepted — 60 s after mint |
cap_seconds |
Hard session length the box enforces |
profile |
Which text layer ran: source is param (you pinned it), default (the avatar’s default), or none |
tools_resolved |
Present only when tools resolved; the tool names the session can call |
expires_at is a connect deadline, not a session length. Mint immediately before the browser
connects; do not mint at page load and connect a minute later. A session that has connected runs
until cap_seconds or hang-up.
202 queuedEvery GPU seat is occupied, but you were admitted to the queue:
{
"status": "queued",
"queue_ticket": "8a6b2d10-51c4-4f7e-b0a9-71d3e9c4f882",
"position": 3,
"eta_seconds": 45,
"retry_after": 3
}
Also sent as a Retry-After header. Wait retry_after seconds, then re-POST the same body
with queue_ticket added, until you get a 201 or a 503:
async function mintWithQueue(body: Record<string, unknown>) {
let ticket: string | undefined;
for (;;) {
const r = await fetch('https://api.casola.ai/api/v1/sessions', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.CASOLA_SECRET_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify(ticket ? { ...body, queue_ticket: ticket } : body),
});
const out = await r.json();
if (r.status === 201) return out; // ready
if (r.status !== 202) throw new Error(out.error); // 503 fleet_busy, 402 quota, 4xx …
ticket = out.queue_ticket; // keep your place in line
await new Promise((rs) => setTimeout(rs, out.retry_after * 1000));
}
}
Re-POSTing without the ticket abandons your place and starts over at the back of the queue.
| Status | Error | Meaning |
|---|---|---|
503 |
fleet_busy |
Full past the wait horizon, or no box registered. Terminal — do not poll |
503 |
custom_avatar_unavailable |
No live box can currently serve custom avatars. Retryable |
422 |
custom_avatar_unsupported |
The whole fleet lacks custom-avatar support. Not retryable |
402 |
quota | Cumulative minutes exhausted (anonymous demo tier); body carries reset_at + retry_after |
403 |
see doc 1 | Credential is not allowed to send a parameter |
400 |
see doc 6 | Validation |
Full list: 6. Error reference.
Hand connect_url and session_token to the browser and let it connect directly to the GPU
edge. Media never transits your backend or the Casola API.
import { AvatarSession, connectViaToken } from '@casola/avatar-client';
const session = new AvatarSession({
videoEl: document.querySelector('video#avatar')!,
connect: connectViaToken({ connectUrl: connect_url, sessionToken: session_token }),
workletUrl: '/mic-worklet.js', // serve dist/worklet/mic-worklet.js from your own origin
callbacks: {
onFirstFrame() { hideSpinner(); },
onPartial(text) { showInterim(text); }, // live ASR of the user
onTurn(turn) { log(turn.text, turn.reply); },
onAudioBlocked() { showTapForSound(); }, // iOS refused unmuted autoplay
onClose(reason) { showEnded(reason); },
},
});
await AvatarSession.ensureMicPermission();
await session.start();
const turn = await session.sendText('What are your hours?'); // typed turns work too
session.leave();
Under the hood the SDK opens two WebSockets against connect_url, both carrying the session token:
/mse (the fMP4 video/audio downlink) and /mic_stream (the persistent microphone uplink, with
server-side voice activity detection). If you are building your own client rather than using the
SDK, that wire protocol is specified in specs/02-avatar-wire-protocol.md and
specs/09-data-plane-contract.md — but use the SDK unless you have a reason not to.
| Route | Auth | Purpose |
|---|---|---|
POST /api/v1/sessions/:sessionId/heartbeat |
seat token or your key | Keep the seat alive |
POST /api/v1/sessions/:sessionId/release |
seat token or your key | Free the seat immediately |
GET /api/v1/sessions/:sessionId |
sessions:read |
Read the session row |
Both lifecycle routes return {"ok": true} and accept either the seat_token from the mint or a
workspace-authorized key.
Releasing is a courtesy, not an obligation: the edge frees the seat when the browser’s sockets close, and an idle reaper is the backstop. Call it anyway when your UI knows the user is done — it returns capacity in seconds instead of after the grace period, which matters on a small fleet.
await fetch(`https://api.casola.ai/api/v1/sessions/${session_id}/release`, {
method: 'POST',
headers: { authorization: `Bearer ${process.env.CASOLA_SECRET_KEY}` },
});
Everything the avatar runs with is resolved at mint and frozen into the session:
Editing the avatar’s profile mid-call changes nothing for that call. The next mint picks up the change. This is what makes an avatar safe to edit while people are talking to it.