Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Enable an avatar

This walks you from "I have a configured ScaiBot" to "my customer's site has a 3D avatar of it" in one sitting.

Prerequisites#

  • A ScaiBot configured with at least a name, persona, and a voice (Voice Settings → enable + pick a voice_id). The avatar inherits the voice; no avatar without a voice.
  • A 3D model file (GLB, GLTF, or VRM) hosted at a URL your customer's browser can reach. We don't host models; we point at one. CDNs, S3 buckets, or even your own static-asset host all work.
  • A web page where you'll render the avatar (Three.js, react-three-fiber, etc.). The customer hosts the page; we don't ship a 3D client.

1. Turn the avatar on#

In the admin UI, open ScaiBot → Avatar (ScaiVatars). Pick your bot from the dropdown.

Flip Avatar enabled to on. Save the 3D model URL field. The bot is now reachable through the public avatar WebSocket; no other config is required to start testing locally (the origin allow-list will reject browsers until you fill it in, but curl and CLI clients still work).

2. Issue an embed token#

Open ScaiBot → Embed & Deploy. Pick the same bot. Generate an embed token.

Copy the token immediately — the page shows it once at creation time; the plaintext isn't retrievable later. If you lose it, revoke and re-issue.

The same token grants access to the chat widget, the voice widget, and the avatar WebSocket — one credential, three surfaces.

3. Whitelist your customer origins#

Back on the Avatar page, paste your customer's origin into Allowed origins:

  • Exact: https://customer.example.com
  • Wildcard subdomain: https://*.customer.example.com

Press Add. Repeat for staging, dev, kiosk hostnames, etc. The public WebSocket will reject connections from any browser origin not on this list — curl and non-browser clients (which don't send an Origin header) bypass it.

4. Connect from your client#

The wire protocol is fully documented in Avatar API reference. Minimal example to confirm the path works end-to-end:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import asyncio, json, uuid, websockets

TOKEN = "sgemb_…"                          # from step 2
BOT_SLUG = "poolnoodle"                    # bot's slug from the picker

async def main():
    url = f"wss://scaigrid.scailabs.ai/v1/public/scaibot/avatar/ws-interactive?token={TOKEN}"
    async with websockets.connect(url) as ws:
        # Open a session
        await ws.send(json.dumps({
            "type": "request",
            "request_id": str(uuid.uuid4()),
            "payload": {
                "action": "start_session",
                "params": {"avatar_id": BOT_SLUG},
            },
        }))
        ready = json.loads(await ws.recv())
        session_id = ready["payload"]["data"]["session_id"]

        # The server emits an `avatar_update` event right after
        # start_session succeeds.  It carries the 3D config
        # (model_url, blendshape_profile, idle_animation) so the
        # client can load the avatar without a separate HTTP call.
        config_frame = json.loads(await ws.recv())
        if (
            config_frame.get("type") == "event"
            and config_frame["payload"]["event_type"] == "avatar_update"
        ):
            avatar_config = config_frame["payload"]["data"]
            print(f"3D model: {avatar_config.get('model_url')}")
            print(f"idle anim: {avatar_config.get('idle_animation')}")

        # Ask the avatar to say something — text in, voice + blendshapes back
        await ws.send(json.dumps({
            "type": "request",
            "request_id": str(uuid.uuid4()),
            "payload": {
                "action": "text_to_speech",
                "params": {
                    "session_id": session_id,
                    "text": "Hello, world.",
                },
                "response_config": {
                    "include_text": True,
                    "include_voice": True,
                    "include_blendshapes": True,
                },
            },
        }))

        # Receive chunks
        while True:
            frame = json.loads(await ws.recv())
            if frame.get("type") == "response":
                data = frame["payload"]["data"]
                voice_b64 = data.get("voice", {}).get("data", "")
                n_frames = len(data.get("blendshapes", []) or [])
                print(f"got chunk: text={data.get('text')!r}, "
                      f"voice_bytes={len(voice_b64)}, blendshape_frames={n_frames}")
                if frame["payload"].get("chunk_info", {}).get("is_final"):
                    break

asyncio.run(main())

If you get text + base64 voice + a blendshapes array back, you're connected. The voice block is PCM ready to play; the blendshapes are an array of [timecode, c0, c1, ..., c67] per frame — feed them to your 3D model's morph target weights, synced to audio playback time.

To switch from "make the avatar say this text" to "have a conversation with the avatar", change the action to send_text:

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
await ws.send(json.dumps({
    "type": "request",
    "request_id": str(uuid.uuid4()),
    "payload": {
        "action": "send_text",
        "params": {
            "session_id": session_id,
            "text": "What's the difference between a GLB and a GLTF?",
        },
        "response_config": {
            "include_text": True,
            "include_voice": True,
            "include_blendshapes": True,
        },
    },
}))

The server passes your text through the bot's LLM with its persona + the avatar-mode rules block (no Markdown, no "see the diagram" — the LLM knows it's being spoken aloud), synthesizes the reply, and emits a response chunk the same shape as text_to_speech. The conversation history persists against the session; get_history reads it back.

For voice input, send send_voice with base64-encoded PCM or WAV. The server transcribes via ScaiInfer STT, runs the same send_text pipeline on the transcript, and ships the reply. Set response_config.include_transcription=true to get "you said: …" back on the response chunk.

5. Drive the 3D model#

A barebones Three.js + GLTF skeleton. Note how the model URL comes from the avatar_update event the server pushes right after start_session — no hardcoded URL in the client code:

javascript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

// The avatar config arrives via the `avatar_update` event right
// after start_session succeeds.  Register the handler before you
// send start_session.
let avatarConfig = null;
ws.addEventListener('message', (e) => {
  const frame = JSON.parse(e.data);
  if (
    frame.type === 'event' &&
    frame.payload.event_type === 'avatar_update'
  ) {
    avatarConfig = frame.payload.data;
    // Load the model the server picked for this bot:
    loader.loadAsync(avatarConfig.model_url).then((gltf) => {
      scene.add(gltf.scene);
      // Play the bot's configured idle animation, if any:
      if (avatarConfig.idle_animation) {
        playAnimation(gltf, avatarConfig.idle_animation);
      }
    });
  }
});

const loader = new GLTFLoader();
const gltf = await loader.loadAsync(avatarConfig.model_url);
const head = gltf.scene.getObjectByName('Head');  // mesh with morph targets

// You received `blendshapes` and `voice.data` from the WebSocket.
const audio = new Audio(`data:audio/wav;base64,${voice_b64}`);
audio.play();

const FPS = 60;
const startMs = performance.now();
function tick() {
  const elapsedMs = (performance.now() - startMs);
  const frameIdx = Math.floor((elapsedMs / 1000) * FPS);
  const frame = blendshapes[frameIdx];
  if (frame) {
    // frame = ["00:00:00.000", c0, c1, ..., c67]
    for (let i = 0; i < head.morphTargetInfluences.length; i++) {
      head.morphTargetInfluences[i] = frame[i + 1] ?? 0;
    }
  }
  if (frameIdx < blendshapes.length) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

The morph target order on the head mesh has to match the ARKit ordering the lipsync engine emits — most VRM and ARKit-blendshape-equipped GLTF models do this by convention. If your model uses a custom order, build a one-time index map at load time.

Next steps#

  • Tune the system prompt. Avatar mode rules are appended automatically — they teach the LLM not to use Markdown (TTS would read the asterisks aloud), not to reference visual artifacts that don't exist in speech, and to know it's being shown as a 3D avatar. Add an avatar_system_prompt on the bot to give it a custom personality on the avatar surface (separate from text and voice).
  • Customize the idle animation. Set the avatar_idle_animation field to the animation name your 3D model bundles for "not speaking" loops. The WebSocket only ships speaking-time blendshapes; idle motion is your client's responsibility.
  • Watch the sessions panel. Tenant admins can see active and recent avatar sessions on the Avatar Settings page. Use it for debug; for production observability the standard audit log applies.
Updated 2026-06-23 15:25:31 View source (.md) rev 3