High-level TypeScript SDK for building Node.js bots that connect to metatell rooms.
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.
MetatellClientEvents.enableVoice().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