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_busyCreate a session
POST /api/v1/sessionsRequired scope: sessions:write (secret key) or session:connect
(publishable key — stock personas only, origin-checked).
Selecting the avatar
Pick exactly one. Full semantics: Avatars.
| Field | Type | Notes |
|---|---|---|
avatar_id | string | The avatar — resolves to whatever it currently publishes. The form you usually want. Secret keys only |
avatar_ref | string | A specific version id. Secret keys only |
avatar_version_id | string | Explicit version pin, combinable with avatar_id. Secret keys only |
persona | string | Stock persona key from the public gallery ("mei"). Works without a workspace |
voice_ref | string | Voice version id override. Secret keys only |
workspace_id | string | Only when the key is not workspace-bound |
{ "avatar_id": "019fe463-fbd3-7000-80e8-85dad81d3fee" }Shaping behaviour
All secret-key only. Full contract: Prompt & overrides.
| Field | Type | Notes |
|---|---|---|
profile_id | string | Pin the text layer to a specific profile |
context.system_prompt | string | Replaces the behaviour prompt (≤ 40,960 chars) |
context.backstory | string | Replaces the backstory (≤ 16,384) |
context.history | array | Prior turns to seed the conversation (≤ 40 messages / 16,384 bytes) |
extra_system_prompt | string | Appended to whatever won (≤ 2,048) |
extra_backstory | string | Appended to the backstory block (≤ 2,048; combined extras ≤ 3,072) |
tools | object | Replaces stored tool config — see Tool calling |
extra_tools | object | Merges into whatever tools resolved |
agent | object | Your hook / tok / conv; required to run inline tools |
config | object | Config group override, per key (≤ 1,024 bytes) |
Session shape
| Field | Type | Notes |
|---|---|---|
response_language | string | BCP-47 tag ("en") or display name ("English"). A preference, not a lock — [Core] still mirrors the user’s language |
session_cap_seconds | integer | 60–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 |
recording | boolean | Default 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”) |
observability | boolean | Deprecated — 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_allowlist | string[] | Origins permitted to connect with the minted token. Publishable keys: overridden by the key’s registered origins |
queue_ticket | string | Only 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"]}| Field | Description |
|---|---|
connect_url | The assigned edge’s https base URL. Derive the WebSocket URL by swapping https → wss: /v2/session (subprotocol casola.avatar.v2) on protocol v2, the /mse + /mic_stream pair on v1. Different per mint — never cache it |
session_token | Short-lived (≈60 s) EdDSA JWT the edge verifies offline. Browsers pass it as ?token=; servers/native as Authorization: Bearer |
seat_token | Opaque seat handle for heartbeat/release. Server-side only — never hand it to a browser you don’t control |
expires_at | Unix seconds when session_token expires. A connect deadline, not a session length |
cap_seconds | Maximum live-session duration once the avatar is live. Can be lower than the environment default when quota is nearly exhausted |
profile | Which text layer ran — source is param, default or none. See overrides |
tools_resolved | Present only when tools resolved; the tool names this session may call. See Tool calling |
protocol_version | The 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:
| Error | Meaning |
|---|---|
rate_limited | Per-key (or per-device) mint rate guard tripped |
concurrency_limit | Your 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:
| Error | Parameter refused |
|---|---|
custom_avatar_not_allowed | avatar_ref / voice_ref from a publishable key |
avatar_version_pin_not_allowed | avatar_version_id |
profile_pin_not_allowed | profile_id |
tools_context_not_allowed | tools, extra_tools, context.*, config, extra_* |
session_cap_not_allowed | session_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}/transcriptRequired 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.