LevelChatLevelChatDocs

Documentation

Migrate from Twilio Video

A CLI codemod ports the bulk of a twilio-video integration before its December 2026 EOL.

Twilio shut down Programmable Video; its end-of-life is December 2026. The @levelchat/cli package is a codemod that rewrites the bulk of a twilio-video integration to @levelchat/web — it prints a unified diff you review before anything is written, and it's free whether you pick LevelChat or not.

~
npx @levelchat/cli migrate twilio

What it does

migrate twilio walks your source tree, finds every file that imports twilio-video, applies a set of named, idempotent rewrites, and prints a unified diff of what would change. It does not touch your files until you ask it to.

~
# 1. Dry run — review the diff
npx @levelchat/cli migrate twilio

# 2. Apply the rewrites in place
npx @levelchat/cli migrate twilio --write

# 3. Or scan just one subtree
npx @levelchat/cli migrate twilio --cwd ./packages/app/src

The dry-run output is git apply-compatible, so you can also do:

~
npx @levelchat/cli migrate twilio > twilio-migration.patch
# review twilio-migration.patch, then:
git apply twilio-migration.patch

The API mapping

Every row below is what the CLI actually rewrites, verified against the real @levelchat/web SDK surface.

Twilio VideoLevelChat
import Video from 'twilio-video'import { LevelChat } from '@levelchat/web'
import { connect } from 'twilio-video'import { LevelChat } from '@levelchat/web'
require('twilio-video')require('@levelchat/web')
Video.connect(token, opts)new LevelChat().joinRoom({ token })
connect(token, opts)new LevelChat().joinRoom({ token })
room.disconnect()room.leave()
'participantConnected''participant-joined'
'participantDisconnected''participant-left'
'trackSubscribed''track-subscribed'
'trackUnsubscribed''track-unsubscribed'
'trackPublished' / 'trackUnpublished''track-published' / 'track-unpublished'
'dominantSpeakerChanged''active-speaker'
'disconnected' / 'reconnecting' / 'reconnected''connection-state' (one event — the handler receives the state string)

Left untouched on purpose:

  • room.participants — Twilio's is a Map, the web SDK's is a ReadonlyMap getter. Both are map-like property access (.values(), .get(), .size), so the CLI does not rewrite it — a rewrite would only break working code.
  • participant.videoTracks / .audioTracks — the web SDK does not expose tracks off the participant; it surfaces them through the room's track-subscribed event. A regex can't express that restructure, so the CLI leaves these call sites alone and flags them in the Manual follow-ups block.

What you still do by hand

The CLI is regex-based, not a full AST transform — on purpose. The output is a diff a human reviews, not a silent rewrite. After running it, the tool prints a Manual follow-ups block flagging the call sites that need you:

  • Token endpoint — LevelChat mints JWTs on your server, exactly like Twilio. Keep your token endpoint; joinRoom({ token }) only needs the JWT, not Twilio's options object. See the Quickstart for the token-minting snippet.
  • Local tracksVideo.createLocalVideoTrack() / createLocalAudioTrack() map to joinRoom() options + room.publishCamera() / room.publishMic(). Review these call sites by hand.
  • UI components — if you were rendering Twilio tracks into your own <video> elements, consider swapping in the @levelchat/react-components kit — <VideoTile>, <ParticipantGrid>, <ControlBar> are drop-in.

A before / after

TypeScriptBefore — twilio-video
import Video from 'twilio-video';

export async function joinCall(token: string) {
  const room = await Video.connect(token, { audio: true, video: true });
  room.on('participantConnected', (p) => console.log('joined', p.identity));
  room.on('disconnected', () => console.log('bye'));
  for (const participant of room.participants.values()) {
    participant.videoTracks.forEach((t) => attach(t));
  }
  return () => room.disconnect();
}
TypeScriptAfter — npx @levelchatcli migrate twilio --write
import { LevelChat } from '@levelchat/web';

export async function joinCall(token: string) {
  const room = await new LevelChat().joinRoom({ token: token });
  room.on('participant-joined', (p) => console.log('joined', p.identity));
  room.on('connection-state', () => console.log('bye'));
  for (const participant of room.participants.values()) {
    // [!] Manual follow-up — flagged by the CLI: the web SDK surfaces
    //   tracks via the room's `track-subscribed` event, not off the
    //   participant. The CLI leaves this line untouched on purpose.
    participant.videoTracks.forEach((t) => attach(t));
  }
  return () => room.leave();
}

room.participants is left as-is — the web SDK's room.participants is a ReadonlyMap, so .values() works exactly like it did on Twilio. The videoTracks line is the one spot a regex can't safely rewrite, so the CLI leaves it and tells you about it in the Manual follow-ups block.

Migrating from something other than Twilio?

The CLI's migrator registry is open for extension. Agora, Daily, and Vonage migrators are on the roadmap — if you need one sooner, email [email protected] with a couple of representative files and we'll prioritise it.