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 twilioWhat 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/srcThe 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.patchThe API mapping
Every row below is what the CLI actually rewrites, verified against the real
@levelchat/web SDK surface.
| Twilio Video | LevelChat |
|---|---|
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 aMap, the web SDK's is aReadonlyMapgetter. 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'strack-subscribedevent. A regex can't express that restructure, so the CLI leaves these call sites alone and flags them in theManual follow-upsblock.
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 tracks —
Video.createLocalVideoTrack()/createLocalAudioTrack()map tojoinRoom()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-componentskit —<VideoTile>,<ParticipantGrid>,<ControlBar>are drop-in.
A before / after
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();
}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.