Skip to content

Sessions

A session is one live, full-duplex conversation seat plus a frozen snapshot of everything the avatar will run with. Mint it here, then connect your client directly to the returned edge — see the data plane for the WebSocket protocol.

POST /api/v1/sessions ──▶ 201 ready ──▶ client connects to connect_url
│ │
├──▶ 202 queued ──(poll)──┘ ├──▶ heartbeat (optional)
│ └──▶ release / hang up
└──▶ 503 fleet_busy

Try it →

Create a session

POST /api/v1/sessions

Required scope: sessions:write (secret key) or session:connect (publishable key — stock personas only, origin-checked).

Selecting the avatar

Pick exactly one. Full semantics: Avatars.

FieldTypeNotes
avatar_idstringThe avatar — resolves to whatever it currently publishes. The form you usually want. Secret keys only
avatar_refstringA specific version id. Secret keys only
avatar_version_idstringExplicit version pin, combinable with avatar_id. Secret keys only
personastringStock persona key from the public gallery ("mei"). Works without a workspace
voice_refstringVoice version id override. Secret keys only
workspace_idstringOnly when the key is not workspace-bound
{ "avatar_id": "019fe463-fbd3-7000-80e8-85dad81d3fee" }

Shaping behaviour

All secret-key only. Full contract: Prompt & overrides.

FieldTypeNotes
profile_idstringPin the text layer to a specific profile
context.system_promptstringReplaces the behaviour prompt (≤ 40,960 chars)
context.backstorystringReplaces the backstory (≤ 16,384)
context.historyarrayPrior turns to seed the conversation (≤ 40 messages / 16,384 bytes)
extra_system_promptstringAppended to whatever won (≤ 2,048)
extra_backstorystringAppended to the backstory block (≤ 2,048; combined extras ≤ 3,072)
toolsobjectReplaces stored tool config — see Tool calling
extra_toolsobjectMerges into whatever tools resolved
agentobjectYour hook / tok / conv; required to run inline tools
configobjectConfig group override, per key (≤ 1,024 bytes)

Session shape

FieldTypeNotes
response_languagestringBCP-47 tag ("en") or display name ("English"). A preference, not a lock — [Core] still mirrors the user’s language
session_cap_secondsinteger60–3600. A request: the granted value comes back as cap_seconds, lowered by your plan’s per-session cap, remaining quota, and the seat you land on. Box-enforced hard stop. Secret keys only
recordingbooleanDefault false — and the field to use for capture. true opts this session into raw A/V capture (played clips, end-user microphone audio, whole-session assembly; 30-day retention) on recording-aware renderers. Refused with 403 recording_not_allowed on the anonymous demo tier, on zero-data-retention workspaces, and where recording eligibility is switched off. Controls raw media only — transcripts are independent. You owe your end users the capture disclosure (Terms, “Session capture”)
observabilitybooleanDeprecated — use recording. The pre-split combined switch, kept working for existing integrations. false is still full suppression on every renderer generation: no capture and no session-tagged logs. true opts into capture, exactly like recording: true. Sending neither field records nothing on a recording-aware renderer; when both are sent, recording decides capture and observability: false still silences the logs
domain_allowliststring[]Origins permitted to connect with the minted token. Publishable keys: overridden by the key’s registered origins
queue_ticketstringOnly when polling a queued mint — see below

Response 201 — seat granted

{
"status": "ready",
"session_id": "019fe497-ac9d-7000-804e-3bad9c970397",
"connect_url": "https://box-1.casola.ai",
"session_token": "eyJhbGciOiJFZERTQ…",
"seat_token": "3f21c8ae-9d44-4c1e-9b7a-2e5f0d6c8a13",
"expires_at": 1719000060,
"cap_seconds": 300,
"profile": { "id": "019fe4c1-…", "hash_short": "a2271f20509d", "source": "default" },
"tools_resolved": ["lookup_order"]
}
FieldDescription
connect_urlThe assigned edge’s https base URL. Derive the WebSocket URL by swapping httpswss: /v2/session (subprotocol casola.avatar.v2) on protocol v2, the /mse + /mic_stream pair on v1. Different per mint — never cache it
session_tokenShort-lived (≈60 s) EdDSA JWT the edge verifies offline. Browsers pass it as ?token=; servers/native as Authorization: Bearer
seat_tokenOpaque seat handle for heartbeat/release. Server-side only — never hand it to a browser you don’t control
expires_atUnix seconds when session_token expires. A connect deadline, not a session length
cap_secondsMaximum live-session duration once the avatar is live. Can be lower than the environment default when quota is nearly exhausted
profileWhich text layer ran — source is param, default or none. See overrides
tools_resolvedPresent only when tools resolved; the tool names this session may call. See Tool calling
protocol_versionThe wire protocol selected for this session. Present only when you sent protocol_versions — a mint that did not negotiate gets the byte-identical v1-era response

Mint immediately before connecting — not at page load. A session that has connected then runs until cap_seconds or hang-up.

Response 202 — queued

Every 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,
"state": "waiting"
}

state is waiting or claimable; a claimable answer also carries claim_deadline and means a seat is already reserved for this ticket — re-POST immediately. Full queue contract, including the status and cancel routes: Queue.

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 again at the back of the queue. Showing position and eta_seconds in your UI is a much better experience than a silent spinner. When the user stops waiting, DELETE /api/v1/sessions/queue/{ticket} frees their place — and any seat reserved for them — immediately.

Failure responses

503 fleet_busy — the line is full past the admission horizon, or no box is registered. Terminal for this attempt; do not poll.

{ "error": "fleet_busy", "message": "All compatible renderers are currently at capacity.", "retryable": true }

503 protocol_unavailable — no live renderer speaks the wire protocol this request selected.

{ "error": "protocol_unavailable", "message": "No renderer in the fleet speaks the requested protocol version.", "retryable": true }

This is a statement about the fleet’s generation, not its capacity, so retrying an unchanged request will keep failing for as long as the fleet stays as it is. Getting it without sending protocol_versions means you are on v1: omitting the field selects the v1 wire, and every live renderer now speaks v2. Send protocol_versions: [2] and connect to {connect_url}/v2/session.

503 custom_avatar_unavailable — no live box has been verified for custom-avatar rendering. Retry with backoff; stock personas usually still work, which makes a stock fallback a reasonable degradation path.

422 custom_avatar_unsupported — every registered renderer was assessed as unsupported. Retrying the same request will not help.

402 — cumulative minutes exhausted (anonymous demo tier); the body carries reset_at and retry_after.

429 — two distinct refusals share the status; branch on error:

ErrorMeaning
rate_limitedPer-key (or per-device) mint rate guard tripped
concurrency_limitYour plan’s concurrent-session ceiling is already in use. Body carries limit, active, retry_after; the Retry-After: 15 header is advisory — a seat frees on release or expiry, not on a schedule. This is your workspace’s own ceiling, distinct from 202 (queued for platform capacity) and 503 fleet_busy — a 429 never carries a queue_ticket

403 — your credential is not allowed to send a parameter you sent:

ErrorParameter refused
custom_avatar_not_allowedavatar_ref / voice_ref from a publishable key
avatar_version_pin_not_allowedavatar_version_id
profile_pin_not_allowedprofile_id
tools_context_not_allowedtools, extra_tools, context.*, config, extra_*
session_cap_not_allowedsession_cap_seconds

400 — validation: avatar_ref_avatar_id_exclusive, avatar_ref_persona_exclusive, avatar_not_published, version_not_published, profile_mismatch, invalid_context_system_prompt, invalid_config (+ detail), invalid_response_language, invalid_session_cap_seconds, invalid_recording, invalid_observability. Zero-data-retention workspaces add zdr_memory_unavailable (context.memory_id) and zdr_tools_unavailable (tools / context / extras) — they store no customer content on the platform.

Get a session

GET /api/v1/sessions/{sessionId}

Required scope: sessions:read

Returns the session record including its current status: pending, active, ended, or deleted once the session has been erased — the row is anonymized, not removed, so the lookup keeps answering.

{
"id": "019...",
"workspace_id": "019...",
"api_token_id": "019...",
"avatar_ref": "019...",
"voice_ref": null,
"status": "pending",
"box_host": "box-1.casola.ai",
"created_at": 1719000000
}

Get a session’s transcript

GET /api/v1/sessions/{sessionId}/transcript

Required scope: transcripts:read (its own grantable scope, deliberately not implied by sessions:read — conversation content is a different sensitivity from session metadata; a wildcard key qualifies). Server-side secret keys only: publishable and device keys are refused. Cross-workspace lookups answer 403.

Returns the conversation as ordered turns:

{
"session_id": "019...",
"source": "store",
"turns": [{ "seq": 1, "user_text": "", "reply_text": "" }],
"count": 1
}

source: "store" — served from the platform transcript store (30-day retention). source: "live" — reconstructed from the renderer’s log stream, covering sessions the store does not. An erased session (below) answers an empty turns with deleted: true and is never reconstructed.

Delete a session

DELETE /api/v1/sessions/{sessionId}

Required scope: sessions:write

Erases the session’s content — transcript turns, tool-call records, agent state, and captured media — and anonymizes the session record (billing attribution survives; the conversation does not). Idempotent: deleting twice is fine. A session that is still live answers 409 session_live; end it first, then delete.

Heartbeat & release

POST /api/v1/sessions/{sessionId}/heartbeat scope: sessions:write (or the seat_token)
POST /api/v1/sessions/{sessionId}/release scope: sessions:write (or the seat_token)

Both return {"ok": true}.

Live media traffic is itself the liveness signal — heartbeat only matters for a seat you are holding while not yet connected. release frees the seat early on a clean client exit, returning capacity to the fleet immediately instead of waiting for the idle reaper.

What is frozen, and when

Everything the avatar runs with is resolved at mint and frozen into the session: the avatar version (face, voice, bundle), the resolved system prompt, backstory, tools and config, the reply language, and the capture setting.

Editing the avatar’s profile mid-call changes nothing for that call; the next mint picks it up. That is what makes an avatar safe to edit while people are talking to it.