metatell Bot SDK
    Preparing search index...

    Module @metatell/bot-sdk

    @metatell/bot-sdk

    High-level TypeScript SDK for building Node.js bots that connect to metatell rooms.

    • Node.js 20 or later. Node.js 24 is recommended and is the version used in CI.
    • ESM runtime.
    • TypeScript 5 or later for TypeScript projects. Use moduleResolution NodeNext, Node16, or Bundler to import @metatell/bot-sdk/navigation.
    npm install @metatell/bot-sdk
    # or
    pnpm add @metatell/bot-sdk
    # or
    yarn add @metatell/bot-sdk

    Install @metatell/bot-realtime as well for LiveKit voice transport:

    npm install @metatell/bot-sdk @metatell/bot-realtime
    
    import { createMetatellClient } from '@metatell/bot-sdk'

    async function main() {
    const client = createMetatellClient({
    serverUrl: 'wss://metatell.app',
    roomId: 'YOUR_ROOM_ID',
    username: 'GuideBot',
    debug: true,
    })

    await client.connect()

    const botInfo = await client.getInfo()
    console.log('Connected as:', botInfo.name)

    client.chat.onMessage(async ({ from, text, mention, reply }) => {
    if (mention?.sessionId === botInfo.sessionId) {
    await reply(`Hello ${from.name ?? 'there'}. You said: ${text}`)
    }
    })

    await client.avatar.moveTo({ x: 1, y: 1.6, z: -2 })
    await client.avatar.rotateTo({ x: 0, y: 180, z: 0 })
    await client.avatar.play({ id: 'walking', loop: false })
    }

    main().catch((error) => {
    console.error(error)
    process.exit(1)
    })

    Pass authToken (an OIDC access token) when the bot needs room-role permissions such as text chat. Other options are avatarId, avatarSrc, defaultAvatarId, and debug.

    • Chat send and receive APIs.
    • Room presence and nearby-user queries.
    • Avatar selection, movement, rotation, and animation playback.
    • Typed events through MetatellClientEvents.
    • Error classes for authentication, transport, protocol, timeout, rate limit, and navigation failures.
    • Logging provider hooks.
    • Optional realtime voice integration through enableVoice().
    • GLB scene preparation and navmesh pathfinding for non-browser runtimes.
    await client.connect()
    await client.disconnect()

    const status = client.getStatus()
    const info = await client.getInfo()
    const sessionId = client.getSessionId()
    await client.chat.send('Hello from a bot.')

    client.chat.onMessage(async ({ from, text, reply }) => {
    console.log(`${from.name ?? from.id}: ${text}`)
    await reply('Thanks for the message.')
    })
    await client.avatar.select('avatar-asset-id')
    await client.avatar.moveTo({ x: 10, y: 0, z: 5 })
    await client.avatar.rotateTo({ x: 0, y: 90, z: 0 })
    await client.avatar.lookAt({ x: 0, y: 1.6, z: 0 })
    const assets = await client.avatar.getAvailableAssets()
    const animations = await client.avatar.getAvailableAnimations()
    await client.avatar.play({ id: 'walking', loop: true })

    moveTo() uses room coordinates. rotateTo() uses Euler angles in degrees. Only idle and walking are presets. Use an ID returned by getAvailableAnimations() for avatar-specific animations.

    const users = await client.room.getUsers()
    const nearby = await client.room.getNearbyUsers(10)
    const cached = client.getUsers()

    Join without spawning an avatar, then let the SDK fetch and parse the room GLB:

    import { createMetatellClient } from '@metatell/bot-sdk'
    import { createNavigationRuntime } from '@metatell/bot-sdk/navigation'

    const client = createMetatellClient({ serverUrl, roomId, authToken })
    await client.connect({ mode: 'join-only' })

    const scene = client.room.getSceneInfo()
    const result = await client.room.prepareNavigation({
    // Custom-domain assets must be added as exact HTTPS origins.
    additionalAllowedOrigins: ['https://cdn.space.customer.example'],
    })
    if (result.status !== 'prepared') throw new Error('A cached snapshot is required for 304')

    const runtime = createNavigationRuntime(result.snapshot)
    const cursor = runtime.samplePoint(Math.random)
    await client.disconnect()

    const avatar = createMetatellClient({ serverUrl, roomId, authToken })
    await avatar.connect({
    initialPosition: cursor.position,
    expectedSceneIdentity: result.snapshot.sceneIdentity,
    })

    prepareNavigation() supports self-contained GLB scenes. It traverses the default scene in tree order, extracts Hubs spawn-point/spawnable waypoint components, and uses the first nav-mesh marker's first mesh for character navigation. It does not fall back to later markers or a rectangular boundary when the selected navmesh or spawn data is invalid. Conditional requests can reuse a caller-owned snapshot by passing its PreviousNavigation validator.

    For protected metatell CDN scenes, the client automatically obtains the room's path-scoped CloudFront signed cookies before fetching the GLB. When authToken is configured, it is sent only to the room cookie endpoint at serverUrl and never to the scene asset URL or the custom fetch passed to prepareNavigation(). For an explicitly allowed https://cdn.<custom-domain> origin, that cookie request identifies <custom-domain> so the returned cookies apply to its CDN without sending the access token to the custom origin.

    Subscribe to room-scene-changed and stop work that depends on the old snapshot. Passing expectedSceneIdentity also prevents a reconnect or avatar entry from using a snapshot prepared for a different scene.

    By default, prepareNavigation() limits the download to 64 MiB, the decoded navmesh to 256 MiB and 500,000 triangles, and the request to 30 seconds. Override them with maxBytes, maxDecodedBytes, maxTriangles, and timeoutMs. Scene, navmesh, and timeout failures reject with NavigationError. Cancelling through signal rejects with an AbortError instead: the signal's reason when it is an AbortError, otherwise a DOMException named AbortError.

    The navigation runtime exposes getSpawnPoints(), samplePoint(), projectPoint(), findPath(), and clampStep(). Keep one runtime per worker and a separate cursor per virtual user or agent.

    client.on('connected', () => {})
    client.on('disconnected', () => {})
    client.on('chat-message', (message) => {})
    client.on('user-join', (user) => {})
    client.on('user-leave', (user) => {})
    client.on('user-moved', (user) => {
    console.log(user.name, user.position)
    })
    client.on('voice:mute-changed', ({ muted }) => {})
    client.on('room-scene-changed', ({ previousIdentity, current }) => {})

    Position updates are pushed over NAF. user-moved and getNearbyUsers() use the presence session ID (same space as user-join / user-leave) when it can be resolved. getNearbyUsers() reads a snapshot of the same cache.

    import { enableVoice } from '@metatell/bot-sdk'

    // Call after client.connect(). Audio is 48000 Hz, mono, signed 16-bit PCM.
    const voice = await enableVoice(client, {
    transport: { type: 'livekit' },
    handlers: {
    onRemotePcm: async (pcm, meta) => {
    console.log('audio frame from', meta.fromIdentity, pcm.length)
    },
    },
    })

    await voice.detach()
    import { AuthenticationError, TransportError } from '@metatell/bot-sdk'

    try {
    await client.connect()
    } catch (error) {
    if (error instanceof AuthenticationError) {
    console.error('Authentication failed:', error.message)
    } else if (error instanceof TransportError) {
    console.error('Connection failed:', error.reason ?? error.message)
    } else {
    throw error
    }
    }

    MIT