Skip to content

Calls ​

Twitter has two calling systems, and emusks speaks both:

  • XChat calls (client.xchat) are the 1:1 and group audio and video calls inside XChat conversations. They run on Periscope signaling and a Janus SFU.
  • Twitter Calls (client.xcalls) is Twitter's standalone meeting app at call.x.com, with shareable links, chat, reactions, raised hands, join requests and scheduled calls. It uses its own GraphQL operations, an RTC gateway called Hydra and a binary event socket on chat-ws.x.com.

WebRTC setup ​

Media needs a WebRTC engine. Node and Bun don't ship one, so install @roamhq/wrtc, a prebuilt native binding:

sh
npm i @roamhq/wrtc

emusks loads it the first time you place, answer or join a call. @roamhq/wrtc is a native addon that runs under Node; Bun can't load it yet, so run call scripts with node. Everything that doesn't carry media, such as creating calls, chat and scheduling, works in both.

The file helpers (sendAudioFile, sendVideoFile, recordIncoming) also need ffmpeg on your PATH, or set FFMPEG_PATH.

@roamhq/wrtc can't encode H.264. The official XChat app sends video as H.264, and the Twitter Calls SFU only accepts H.264 in three simulcast layers. With @roamhq/wrtc, XChat video works between two emusks clients but not with the official app, and in Twitter Calls the SFU refuses your video track, so the call carries on with audio and emits videoRejected. Audio is Opus everywhere and works. To use an H.264-capable engine, pass it to useWebRTC.

XChat calls ​

xchat.useWebRTC(engine) ​

Sets the WebRTC engine this client uses instead of @roamhq/wrtc, for XChat calls and Twitter Calls. Returns the client.

js
import wrtc from "@roamhq/wrtc";
client.xchat.useWebRTC(wrtc);

xchat.call(recipient, opts?) ​

Places a 1:1 call and returns a call object. recipient is a numeric id, @handle or { id }.

js
const call = await client.xchat.call("elonmusk");

call.on("connected", () => console.log("call is up"));
call.on("track", ({ kind }) => console.log("receiving", kind));
call.on("ended", ({ durationSeconds }) => console.log("call ended after", durationSeconds, "s"));
OptionDefaultDescription
videofalseSend video as well as audio
audioOnlytrueAudio only. Ignored when video is set.
announcetrueAlso post the call-started event into the conversation. Needs a loaded identity.

xchat.listenForCalls(handler, opts?) ​

Calls handler for every incoming 1:1 call and returns { close() }. It listens on Twitter's live event stream and falls back to polling your inbox every opts.pollMs (default 4000) if the stream fails. opts.transport: "poll" skips the stream.

js
const sub = client.xchat.listenForCalls(async (incoming) => {
  console.log("call from", incoming.hostId);
  const call = await incoming.accept({ video: false });
});

sub.close();

Each incoming has broadcastId, hostId, conversationId, audioOnly, source, accept(opts?) and decline().

xchat.answerCall(incoming, opts?) ​

Answers a call whose broadcastId and hostId you already know. incoming.accept() calls this for you. Takes the same video and audioOnly options as call().

js
const call = await client.xchat.answerCall({ broadcastId, hostId }, { video: true });

xchat.startGroupCall(opts?) ​

Starts a group call on the Janus SFU (the Twitter Spaces backend) and returns a call object with a broadcastId. Invite people with invite, since Twitter only lets invited users join.

js
const call = await client.xchat.startGroupCall({ invite: ["alice", "bob"] });
console.log("share this id:", call.broadcastId);
OptionDefaultDescription
video, audioOnlyaudio onlySame as call()
invitenoneUser id, @handle, or an array of them
conversationIdnoneXChat conversation to tie the call to
videoCodec"vp8"Room video codec. Pass "h264" only with an H.264-capable engine.

xchat.joinGroupCall(broadcastId, opts?) ​

Joins a group call by its broadcast id. Takes video and audioOnly.

js
const call = await client.xchat.joinGroupCall(broadcastId);

The XChat call object ​

call(), answerCall(), startGroupCall() and joinGroupCall() return an EventEmitter with these events:

  • connected: the peer connection is up and media flows.
  • track { kind, track }: a remote audio or video track arrived.
  • mediastatus { isCameraDeactivated, isMicrophoneDeactivated }: the other side toggled their mic or camera (1:1 calls).
  • live: the group call went live (group calls, host only).
  • ended { reason, durationSeconds }: the call finished.
  • error: a non-fatal signaling error.

Send media from a file, record what you receive, and hang up:

js
const audio = await call.sendAudioFile("./hello.mp3");  // { done, stop() }
const clip = await call.sendVideoFile("./clip.mp4");    // { video, audio, done }, needs { video: true }

const rec = call.recordIncoming({ audioPath: "./in.wav", videoPath: "./in.mp4" });
await rec.audio?.stop();
await rec.video?.stop();

await call.hangup();
call.durationSeconds;

Files are transcoded and streamed in real time. For raw frames, listen for track and attach your own sink from the WebRTC engine.

Twitter Calls ​

client.xcalls hosts and joins call.x.com meetings, as a logged-in user or as a guest, and can broadcast audio into a call and record everyone else.

js
import Emusks from "emusks";

const client = new Emusks();
await client.login(process.env.AUTH_TOKEN);

const call = await client.xcalls.start();
console.log("join at", call.url);

call.on("publisher", ({ userId }) => console.log(userId, "started sending media"));
call.on("chat", ({ text, from }) => console.log(from.id, "said", text));

await call.sendAudioFile("./intro.mp3");
const rec = call.recordIncoming({ audioPath: "./everyone.wav" });

await rec.stop();
await call.leave();

xcalls.start(opts?) ​

Creates a call, publishes your media to the SFU, announces you as the host and opens the event socket. It resolves once you're live, with an XCall whose created property holds the create() result. It takes the join options plus conversationId, to tie the call to an XChat conversation.

js
const call = await client.xcalls.start({ audioOnly: true });

xcalls.join(id, opts?) ​

Joins a call and returns an XCall. id can be abc-def-ghj, abcdefghj or a full https://call.x.com/... link.

js
const call = await client.xcalls.join("https://call.x.com/abc-def-ghj");

A client that never called login() joins open calls as a guest. emusks activates a guest token and keeps the guest_id cookie the same for the GraphQL API and the RTC gateway, which rejects mismatched guests with jwt sub mismatch.

js
const guest = new Emusks();
const call = await guest.xcalls.join("abc-def-ghj", { guestScreenName: "meeting bot" });

A closed call needs the host's approval. With knock: true, join sends a join request, waits on the event socket for the host to accept, then joins. Twitter refuses join requests from guests, so knocking needs a logged-in client.

js
const call = await client.xcalls.join(id, { knock: true, knockTimeoutMs: 60_000 });
OptionDefaultDescription
audioOnlytruePublish only audio
videofalsePublish a camera track too (see the H.264 note in WebRTC setup)
guestScreenNamenoneDisplay name for guests, up to 50 characters
knockfalseAsk the host first when the call is closed
knockTimeoutMs120000How long to wait for the host
onKnocknoneCalled once the join request is sent
subscribetrueReceive every other participant's media
eventstrueOpen the chat-ws event socket
onnone{ event: handler } listeners attached before media starts
webrtcautoA WebRTC engine to use instead of @roamhq/wrtc

The XCall object ​

start() and join() return an XCall, which is an EventEmitter.

PropertyDescription
callId, urlThe call's id and share link
info{ hostUserId, participants, audioOnly, createdAt, ... } from the join
self{ id, guest }, who this client is in the call
feedsMap of other publishers by feed id
remoteTracksEvery track received so far
publisherFeedId, subscriberFeedId, sessionIdThis client's two connections and its RTC session
mediaSessionIdThe media session id Twitter returned on join, if any
credentialsThe parsed media_credentials from the join
createdThe create() result, only on calls made with start()
membersThe latest participant list from a members event
videoRejectedtrue when the SFU refused your video track
durationSecondsSeconds since media connected
endReasonleft, hung_up, session_lost, publisher_left or failed
lastErrorThe last error raised while no error listener was attached

Media events:

  • connected: your media reached the SFU.
  • joined info: Twitter registered you as a participant.
  • publisher { feedId, userId, tracks }: someone started sending media.
  • publisherLeft { feedId, userId }: they stopped.
  • track { kind, track, feedId, userId, label }: a remote track arrived. label is audio, camera or screen.
  • connection { feedId, status, reason }: the gateway reported a peer hanging up.
  • state { leg, state }: a peer connection changed state.
  • slots slots: the SFU's video slot layout, in slot mode.
  • rtc event: every raw event from the RTC gateway's long poll, before emusks handles it.
  • videoRejected: the SFU refused your video track.
  • ended { reason, durationSeconds }.
  • error. Errors raised before you attach a listener go to call.lastError instead of crashing the process. Pass on: { error } to join() or start() to catch the ones raised while connecting.

Call events come from the event socket. Each carries from, the member who caused it, as { id, userId, guest, guestMemberId }, and member, the member it is about.

EventPayload
chat{ text }
reaction{ emoji }
mute, unmute{ member }
hand{ member, raised }
camera, screenshare{ member, on }
joinrequest{ requestedAtMs }
joinaccepted, joinrejected{ member }
calltype{ callType }
members{ participants }
join, leave{ joinedAtMs } or { leftAtMs }

Every decoded event is also emitted as event, including ones without a short name such as subscriberOffer, callMetadata and p2pSignal. Your own chat messages and reactions are not echoed back.

Actions in the call. Twitter only accepts these from people who are in the call:

js
await call.chat("hello everyone");
await call.react("🔥");
await call.raiseHand();
await call.lowerHand();
await call.mute();            // disables your audio track and tells everyone
await call.unmute();
await call.cameraOn();
await call.cameraOff();
await call.screenShareOn();
await call.screenShareOff();

call.on("joinrequest", (e) => call.acceptJoin(e.from));   // or call.rejectJoin(e.from)
await call.setCallType("closed");                          // "open", "closed" or "trusted"

await call.pin(["con_p_1a2b3c4d"]);                         // pin feeds in slot mode
await call.subscribe(["con_p_1a2b3c4d"]);                   // choose whose media to receive

Media and leaving:

js
const audio = await call.sendAudioFile("./song.mp3");   // { done, stop() }
await audio.done;

const clip = await call.sendVideoFile("./clip.mp4");    // { video, audio, done }, needs { video: true }

const rec = call.recordIncoming({ audioPath: "./call.wav" });
await rec.stop();

await call.leave();

With a string path, recordIncoming records the first remote audio and video track. Pass a function to get one file per participant:

js
call.recordIncoming({ audioPath: ({ userId, feedId }) => `./audio-${userId ?? feedId}.wav` });

leave() tells Twitter you left, closes both peer connections and deletes the RTC session. hangup() does the same and records hung_up as the reason.

Call operations ​

Every operation the Twitter Calls web app uses has a method, so you can drive calls without an XCall. id accepts any call id shape.

MethodReturns
xcalls.create(opts?)A new call: { callId, url, credentials, mediaSessionId, ... }. Takes audioOnly, video, conversationId.
xcalls.info(id)Pre-join data: { callId, url, title, callType, memberCount, viewerMayJoin, token }
xcalls.authToken(id)The chat-ws token
xcalls.requestToken(id)A media grant: { gatewayUrl, streamJwt, iceUrls, ... }
xcalls.recoverMedia(id)A new grant after losing media
xcalls.requestJoin(id, opts?)true when Twitter accepted your join request to a closed call
xcalls.announce(id, { publisherFeedId, ... })Makes you a participant once your publisher is live (xCallsJoinCall)
xcalls.leaveCall(id, opts?)true on success
xcalls.sendEvent(id, event, opts?)Sends a call event such as { type: "chat", text: "hi" }. Guests pass opts.as.
xcalls.events(id, handler)Opens the event socket. handler is a function or { onEvent, ... }.
xcalls.sendSignal(id, { sessionId, kind, payload, seq })Sends a peer-to-peer signal
xcalls.fetchSignals(id, { sessionId })Fetches peer-to-peer signals
js
const created = await client.xcalls.create();
const info = await client.xcalls.info(created.callId);
await client.xcalls.sendEvent(created.callId, { type: "chat", text: "hi" });

const sub = await client.xcalls.events(created.callId, (event) => console.log(event.type, event));
sub.close();

Twitter decides which calls are peer to peer. On group calls sendSignal and fetchSignals fail with not a P2P call.

xcalls.rtc(id, opts?) ​

Returns a connected RtcSidecar for running WebRTC against the Hydra gateway yourself. Pass opts.grant to reuse a grant, or connect: false to skip connecting.

js
const rtc = await client.xcalls.rtc(id);
await rtc.offer("con_p_1a2b3c4d", sdp, { 0: "audio" });
const { cursor, events } = await rtc.poll("0");
await rtc.leave();

It has connect, offer, answer, subscribe, trickle, restart, pins, poll and leave, plus setGrant(grant) to swap in a renewed stream token. It renews the token itself when the gateway answers 401. The Twitter Calls protocol page describes what each one sends.

Scheduled calls ​

js
const event = await client.xcalls.schedule({
  startsAt: new Date("2026-10-10T16:00:00Z"),
  durationMin: 30,
  title: "weekly sync",
  invite: ["44196397"],
  rrule: "FREQ=WEEKLY",
});

await client.xcalls.scheduled({ from: Date.now(), to: Date.now() + 7 * 86_400_000 });
await client.xcalls.userScheduled(userId, { from, to });
await client.xcalls.updateScheduled(event.eventId, { title: "renamed", startsAt, durationMin: 45 });
await client.xcalls.rsvp(event.eventId, "going");                 // "going", "maybe" or "declined"
await client.xcalls.setReminder(event.eventId, 10);
await client.xcalls.setEventMetadata(event.eventId, { color: "blue" });
await client.xcalls.cancelScheduled(event.eventId, { scope: "ALL" });
await client.xcalls.refreshExternal({ from, to });
await client.xcalls.searchPeople("tiago");

schedule needs startsAt and defaults to 30 minutes. scheduled defaults to a window from one day ago to eight days after that. Methods that change an event take occurrenceStartMin and scope ("THIS" by default) to target one occurrence of a recurring event.

WARNING

Twitter currently limits scheduling and calendar linking to Twitter, SpaceX, xAI and Tesla affiliates. Other accounts get PermissionDenied from schedule, linkCalendar and the other write operations. Listing, searching people, unlinking and calendar metadata work for everyone.

Calendars and room devices ​

js
await client.xcalls.calendarLinks();                                  // [{ provider, accountEmail, status }]
await client.xcalls.linkCalendar("google", { code, redirectUri });    // after the OAuth consent screen
await client.xcalls.unlinkCalendar("outlook");
await client.xcalls.setCalendarMetadata({ calendars: { holidays: { color: "red" } } });
await client.xcalls.claimRoomDevice({ roomId, code, oauthCode, redirectUri });

Other operations ​

The web app also ships some general operations, which client.xcalls exposes: viewer(), usersByIds(ids), hoverCard(screenName), delegateAccounts(), chatPostCard(tweetId), follow(userId), unfollow(userId), cancelFollowRequest(userId), unblock(userId), initializeMediaUpload({ conversationId, messageId, totalBytes }) and finalizeMediaUpload({ conversationId, messageId, mediaHashKey, resumeId, numParts, ttlMsec?, isPublic? }).

Helpers ​

js
client.xcalls.callId("https://call.x.com/abcdefghj");   // "abc-def-ghj", throws on anything else
client.xcalls.url("abcdefghj");                         // "https://call.x.com/abc-def-ghj"

const payload = client.xcalls.encodeCallEvent(callId, { type: "chat", text: "hi" }, { id: myUserId });
const event = client.xcalls.decodeCallEvent(Buffer.from(payload, "base64"));
const { type, contents } = client.xcalls.peelFrame(websocketFrame);   // unwrap a chat-ws frame

encodeCallEvent takes the event types mute, unmute, raiseHand, lowerHand, cameraOn, cameraOff, screenShareOn, screenShareOff, acceptJoin, rejectJoin, changeCallType, reaction, chat and debug.

client.xcalls.graphql(name, variables) calls any Twitter Calls operation by name, and client.xcalls.operations() lists all 37 as { name, id, kind }.

not affiliated with X Corp.