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 onchat-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:
npm i @roamhq/wrtcemusks 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.
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 }.
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"));| Option | Default | Description |
|---|---|---|
video | false | Send video as well as audio |
audioOnly | true | Audio only. Ignored when video is set. |
announce | true | Also 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.
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().
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.
const call = await client.xchat.startGroupCall({ invite: ["alice", "bob"] });
console.log("share this id:", call.broadcastId);| Option | Default | Description |
|---|---|---|
video, audioOnly | audio only | Same as call() |
invite | none | User id, @handle, or an array of them |
conversationId | none | XChat 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.
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:
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.
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.
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.
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.
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.
const call = await client.xcalls.join(id, { knock: true, knockTimeoutMs: 60_000 });| Option | Default | Description |
|---|---|---|
audioOnly | true | Publish only audio |
video | false | Publish a camera track too (see the H.264 note in WebRTC setup) |
guestScreenName | none | Display name for guests, up to 50 characters |
knock | false | Ask the host first when the call is closed |
knockTimeoutMs | 120000 | How long to wait for the host |
onKnock | none | Called once the join request is sent |
subscribe | true | Receive every other participant's media |
events | true | Open the chat-ws event socket |
on | none | { event: handler } listeners attached before media starts |
webrtc | auto | A WebRTC engine to use instead of @roamhq/wrtc |
The XCall object
start() and join() return an XCall, which is an EventEmitter.
| Property | Description |
|---|---|
callId, url | The call's id and share link |
info | { hostUserId, participants, audioOnly, createdAt, ... } from the join |
self | { id, guest }, who this client is in the call |
feeds | Map of other publishers by feed id |
remoteTracks | Every track received so far |
publisherFeedId, subscriberFeedId, sessionId | This client's two connections and its RTC session |
mediaSessionId | The media session id Twitter returned on join, if any |
credentials | The parsed media_credentials from the join |
created | The create() result, only on calls made with start() |
members | The latest participant list from a members event |
videoRejected | true when the SFU refused your video track |
durationSeconds | Seconds since media connected |
endReason | left, hung_up, session_lost, publisher_left or failed |
lastError | The last error raised while no error listener was attached |
Media events:
connected: your media reached the SFU.joinedinfo: 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.labelisaudio,cameraorscreen.connection{ feedId, status, reason }: the gateway reported a peer hanging up.state{ leg, state }: a peer connection changed state.slotsslots: the SFU's video slot layout, in slot mode.rtcevent: 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 tocall.lastErrorinstead of crashing the process. Passon: { error }tojoin()orstart()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.
| Event | Payload |
|---|---|
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:
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 receiveMedia and leaving:
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:
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.
| Method | Returns |
|---|---|
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 |
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.
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
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
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
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 frameencodeCallEvent 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 }.