Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1358,6 +1358,61 @@
}
]
},
{
"product": "Calls API",
"tabs": [
{
"tab": "Documentation",
"groups": [
{
"group": "Start here",
"pages": [
"xcalls/introduction",
"xcalls/quickstart",
"xcalls/authentication"
]
},
{
"group": "Guides",
"pages": [
"xcalls/media-websocket",
"xcalls/calling-people",
"xcalls/scheduled-calls",
"xcalls/example-agent",
"xcalls/errors-and-limits"
]
},
{
"group": "API reference",
"pages": [
{
"group": "Calls",
"expanded": true,
"pages": [
"xcalls/api-reference/create-a-call",
"xcalls/api-reference/get-a-call",
"xcalls/api-reference/join-a-call",
"xcalls/api-reference/leave-a-call"
]
},
{
"group": "Scheduled calls",
"expanded": true,
"pages": [
"xcalls/api-reference/list-scheduled-calls",
"xcalls/api-reference/schedule-a-call",
"xcalls/api-reference/get-a-scheduled-call",
"xcalls/api-reference/update-a-scheduled-call",
"xcalls/api-reference/cancel-a-scheduled-call",
"xcalls/api-reference/respond-to-a-scheduled-call"
]
}
]
}
]
}
]
},
{
"product": "Enterprise",
"hidden": true,
Expand Down
4 changes: 4 additions & 0 deletions xcalls/api-reference/cancel-a-scheduled-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json DELETE /2/calls/scheduled/{id}"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/create-a-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json POST /2/calls"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/get-a-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json GET /2/calls/{id}"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/get-a-scheduled-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json GET /2/calls/scheduled/{id}"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/join-a-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json POST /2/calls/{id}/join"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/leave-a-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json POST /2/calls/{id}/leave"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/list-scheduled-calls.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json GET /2/calls/scheduled"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/respond-to-a-scheduled-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json POST /2/calls/scheduled/{id}/rsvp"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/schedule-a-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json POST /2/calls/scheduled"
playground: "none"
---
4 changes: 4 additions & 0 deletions xcalls/api-reference/update-a-scheduled-call.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
openapi: "/xcalls/openapi.json PUT /2/calls/scheduled/{id}"
playground: "none"
---
48 changes: 48 additions & 0 deletions xcalls/authentication.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
title: Authentication
sidebarTitle: Authentication
description: "X Calls API endpoints take a user-context token for the account your agent speaks as; the media WebSocket takes the single-use media token from the join response."
keywords: ["X Calls API authentication", "OAuth 2.0 scopes", "user context", "dm.write", "media token", "OAuth 1.0a"]
---

Every X Calls API endpoint needs a **user-context** token for the account your agent speaks as. App-only bearer tokens are rejected with `403 Unsupported Authentication`, because a call participant is always a user.

## OAuth 2.0

Use the Authorization Code flow with PKCE. The token must carry the endpoint's scopes, or the request fails with `403 Missing OAuth2 Scopes`:

| Endpoint | Scopes |
|:---------|:-------|
| `POST /2/calls` | `tweet.read` `users.read` `dm.write` |
| `GET /2/calls/:id` | `tweet.read` `users.read` `dm.read` |
| `POST /2/calls/:id/join` | `tweet.read` `users.read` `dm.write` |
| `POST /2/calls/:id/leave` | `tweet.read` `users.read` `dm.write` |
| `GET /2/calls/scheduled` | `tweet.read` `users.read` `dm.read` |
| `POST /2/calls/scheduled` | `tweet.read` `users.read` `dm.write` |
| `GET /2/calls/scheduled/:id` | `tweet.read` `users.read` `dm.read` |
| `PUT /2/calls/scheduled/:id` | `tweet.read` `users.read` `dm.write` |
| `DELETE /2/calls/scheduled/:id` | `tweet.read` `users.read` `dm.write` |
| `POST /2/calls/scheduled/:id/rsvp` | `tweet.read` `users.read` `dm.write` |

## OAuth 1.0a

With OAuth 1.0a user context, your app needs the **Read, write, and Direct Messages** permission.

## What your user may do

Calls follow the same rules as calls placed from the X app:

- Your bot joins a call only as its [join policy](/xcalls/introduction#join-policies) allows.
- It can start a call in a conversation only if it is a member of that conversation.
- Only a scheduled call's organizer changes or cancels it, and only its invitees answer it. See [Scheduled calls](/xcalls/scheduled-calls).

## The media token

The media WebSocket does **not** take your OAuth token. It takes the single-use `media_token` from the [join response](/xcalls/api-reference/join-a-call), so the socket can be opened by another process, such as your media server, without sharing user credentials. The token is valid for about 2 minutes (`expires_at`) and works once.

```text
GET wss://api.x.com/2/calls/media
Authorization: Bearer <media_token>
```

Clients that cannot set headers, such as browsers, pass `?token=<media_token>` instead. See [Media WebSocket](/xcalls/media-websocket).
46 changes: 46 additions & 0 deletions xcalls/calling-people.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
title: Calling people
sidebarTitle: Calling people
description: "Ring people from your bot's XChat conversations, answer calls started in a conversation with your bot, and admit people to the closed calls your bot hosts."
keywords: ["call someone", "ring", "answer a call", "conversation_id", "closed call", "join.requested", "join.admit"]
---

<Warning>
The X Calls API is in development. It isn't generally available yet, and endpoints and payloads may still change.
</Warning>

## Calling someone

Start the call in your bot's conversation with them, then join it:

<Steps>
<Step title="Create the call in the conversation">
`POST /2/calls` with `conversation_id`: a 1:1 id (`<user_id>-<user_id>`, as the chat endpoints return it, or `<user_id>:<user_id>`) or a group id (`g…`). Your bot must be a member of the conversation.
</Step>
<Step title="Join it and connect your agent">
`POST /2/calls/:id/join`, then open the [media WebSocket](/xcalls/media-websocket).
</Step>
<Step title="X rings the other members">
When the host (your bot) enters the empty call, X rings the other members: in a group, every other member; in a 1:1 conversation, the other person, if X's messaging trust check allows your account to reach them. Ringing is a VoIP push, which reaches the X and XChat apps on iOS; people on other devices join from the conversation or the call link.
</Step>
</Steps>

If nobody joins within 60 seconds, your agent's session ends with `alone`.

If the conversation already has a call in progress, `POST /2/calls` returns **that** call instead of a new one: its `host_id` is whoever started it, and its title and policy are theirs.

## Answering a call

X does not notify bots of incoming calls yet. When you know someone has started a call in a conversation with your bot, for example because they messaged your bot, `POST /2/calls` with that conversation's id returns the call in progress (`host_id` is the caller), and joining it answers.

Conversation ids are matched exactly, so use the id in the form the caller's call uses (the chat endpoints return 1:1 ids as `<user_id>-<user_id>`). If no call is in progress, the request starts a new one.

## Hosting a closed call

When your bot hosts a `closed` or `trusted` call, people who are not admitted automatically ask to join. Your agent receives `join.requested` and answers with `join.admit` or `join.reject`:

```json
{ "type": "join.admit", "user_id": "783214" }
```

Requests made while your agent is not in the call are delivered once it joins.
60 changes: 60 additions & 0 deletions xcalls/errors-and-limits.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: Errors and limits
sidebarTitle: Errors and limits
description: "The errors X Calls API endpoints return, in the standard X API problem format, and the limits on media tokens, sessions, admission, audio and calls."
keywords: ["X Calls API errors", "problem types", "403", "404", "409", "limits", "media token", "alone timeout"]
---

<Warning>
The X Calls API is in development. It isn't generally available yet, and endpoints and payloads may still change.
</Warning>

## Errors

Errors use the standard X API problem format (`type`, `title`, `detail`). Lookups (`GET /2/calls/:id`, `GET /2/calls/scheduled/:id`) return an unknown or unavailable resource as a `200` with the problem under `errors`, like other X API lookups.

| Status | `type` | When |
|:-------|:-------|:-----|
| `400` | `https://api.x.com/2/problems/invalid-request` | A malformed body (for example an unknown `media.type`) or a value calls reject (an unsupported `sample_rate`, a `conversation_id` that is not an XChat id). `errors[]` says what to fix. |
| `403` | `https://api.x.com/2/problems/unsupported-authentication` | An app-only token. Use a user-context token. |
| `403` | `https://api.x.com/2/problems/missing-scopes` | An OAuth 2.0 token without the endpoint's scopes. |
| `403` | `https://api.x.com/2/problems/oauth1-permissions` | An OAuth 1.0a app without the Direct Messages permission. |
| `403` | `https://api.x.com/2/problems/not-authorized-for-resource` | Your user may not join or act on this call, or change, cancel or answer this scheduled call (only its organizer changes and cancels it; only invitees answer). |
| `403` | `about:blank` | Your user is not a member of `conversation_id`, or may not schedule calls. |
| `404` | `https://api.x.com/2/problems/resource-not-found` | Join or leave on an unknown or ended call, or a change to an unknown scheduled call. |
| `409` | `https://api.x.com/2/problems/conflict` | A change to a scheduled call that was cancelled. |
| `429` | rate limit | Too many requests for your plan. |
| `503` | `about:blank` | Calls are temporarily unavailable, or a conversation's membership could not be checked. Retry with backoff. |

An unknown call looked up with `GET /2/calls/:id`:

```json
{
"errors": [
{
"value": "zzz-zzz-zzz",
"detail": "Could not find call with id: [zzz-zzz-zzz].",
"title": "Not Found Error",
"resource_type": "call",
"parameter": "id",
"resource_id": "zzz-zzz-zzz",
"type": "https://api.x.com/2/problems/resource-not-found"
}
]
}
```

Errors on the media WebSocket arrive as `error` events and `session.ended` reasons. See [Media WebSocket](/xcalls/media-websocket#error-codes).

## Limits

| Limit | Value |
|:------|:------|
| Media token | Single use, valid about 2 minutes (`expires_at`). |
| Sessions per user per call | 1; joining again replaces the earlier session, which ends with `superseded`. |
| Waiting for the host to admit your bot | 5 minutes. |
| Alone in a call | The session ends after 60 seconds. |
| Agent audio queued ahead of playback | 2 minutes; beyond that the oldest audio is dropped. |
| Audio | Mono PCM16 at 8, 16, 24 or 48 kHz; no video. |
| Calls | X calls (XChat). Legacy 1:1 DM calls are not supported. |
| Scheduled call window | `GET /2/calls/scheduled` covers at most 7 days per request. |
117 changes: 117 additions & 0 deletions xcalls/example-agent.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
title: Example agent
sidebarTitle: Example agent
description: "A complete X Calls agent in plain Node.js: join a call, stream its audio, speak, stop when someone interrupts, and leave cleanly; plus how to bridge a realtime speech-to-speech model."
keywords: ["X Calls agent example", "Node.js", "media WebSocket", "realtime model", "speech-to-speech", "barge-in"]
---

<Warning>
The X Calls API is in development. It isn't generally available yet, and endpoints and payloads may still change.
</Warning>

This agent joins a call, plays a greeting tone where your text-to-speech would speak, stops talking when someone interrupts, and logs events and audio levels. It runs on Node.js 22 or later with no dependencies.

```bash
USER_ACCESS_TOKEN=<your bot's OAuth 2.0 user token> node agent.mjs t69-8sb-6z5
```

```javascript agent.mjs
// A minimal X Calls agent: joins a call as your bot, plays a greeting tone
// (stand-in for your TTS), stops talking when someone interrupts, and logs
// what happens. Node.js 22+ with no dependencies.
//
// USER_ACCESS_TOKEN=<your bot's OAuth 2.0 user token> node agent.mjs <call_id>

const API = "https://api.x.com/2";
const RATE = 16000; // Hz, PCM16 mono, both directions

async function join(callId) {
const res = await fetch(`${API}/calls/${callId}/join`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.USER_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
media: { type: "websocket", audio: { encoding: "pcm16", sample_rate: RATE } },
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`join failed: ${res.status} ${JSON.stringify(body)}`);
return body.data;
}

// PCM16 little-endian, whatever the host's byte order.
function tone(hz, seconds) {
const n = Math.round(RATE * seconds);
const view = new DataView(new ArrayBuffer(n * 2));
for (let i = 0; i < n; i++) {
view.setInt16(i * 2, Math.round(6000 * Math.sin((2 * Math.PI * hz * i) / RATE)), true);
}
return view.buffer;
}

function level(buffer) {
const view = new DataView(buffer);
let sum = 0;
for (let i = 0; i + 1 < view.byteLength; i += 2) sum += view.getInt16(i, true) ** 2;
return Math.round(Math.sqrt(sum / Math.max(1, view.byteLength / 2)));
}

function runAgent(session) {
const url = `${session.media_url}?token=${encodeURIComponent(session.media_token)}`;
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
const send = (command) => ws.send(JSON.stringify(command));
let frames = 0;
let loudest = 0;

ws.onmessage = ({ data }) => {
if (data instanceof ArrayBuffer) {
// 20 ms of everyone else in the call: feed it to your model here.
loudest = Math.max(loudest, level(data));
if (++frames % 50 === 0) {
console.log(`heard ${frames / 50}s, loudest ${loudest}`);
loudest = 0;
}
return;
}
const event = JSON.parse(data);
console.log(event.type, JSON.stringify(event));
switch (event.type) {
case "session.joined":
ws.send(tone(440, 2)); // any chunk size; X paces it into the call
send({ type: "audio.mark", name: "greeting" });
break;
case "playback.mark":
if (event.name === "greeting" && !event.cleared) console.log("greeting played");
break;
case "speech.started":
send({ type: "audio.clear" }); // let the person talk
break;
case "session.ended":
ws.close();
break;
}
};
ws.onclose = () => process.exit(0);
process.on("SIGINT", () => {
if (ws.readyState === WebSocket.OPEN) send({ type: "leave" });
else process.exit(0);
});
}

runAgent(await join(process.argv[2]));
```

## Bridging a realtime model

To connect a realtime speech-to-speech model instead, keep the same loop:

1. Join at the model's native rate (for example 24 kHz) so no resampling is needed.
2. Forward each binary frame from X to the model's audio input.
3. Forward each audio chunk the model produces to X as a binary frame.
4. When the model reports that the user started speaking (or X sends `speech.started`), send `audio.clear`.
5. On `session.ended`, close the model session.

See [Media WebSocket](/xcalls/media-websocket) for every event and command.
Loading