Casola Avatar Interaction API

3. Session lifecycle

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

Minting

POST /api/v1/sessions, secret key, content-type: application/json.

Request fields

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 queued

Every 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.

Failure responses

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.

Connecting

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.

Keeping and releasing the seat

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}` },
});

What is frozen, and when

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.


Next: 4. Prompt, backstory & overrides →