Build an agent·续

async connect() { const token = await this.ctx.storage.get("token"); if (!token || this.ws) return;

const url = new URL(AGENT_WS); url.searchParams.set("subscriptions", SUBSCRIPTIONS); const connectionId = await this.ctx.storage.get("connectionId"); if (connectionId) url.searchParams.set("connection_id", connectionId);

// Workers open outbound WebSockets with fetch and an Upgrade header. const res = await fetch(url, { headers: { Upgrade: "websocket", Authorization: Bearer ${token}, "Sec-WebSocket-Protocol": "realtimekit.v1", }, }); const ws = res.webSocket; if (!ws) { console.error("upgrade failed", res.status, await res.text()); if (res.status === 404) await this.ctx.storage.delete("connectionId"); if (res.status >= 500 || res.status === 404) this.retry(); return; }

ws.accept(); this.ws = ws; ws.addEventListener("message", (e) => this.onFrame(JSON.parse(e.data))); ws.addEventListener("close", (e) => this.onClose(e.code, e.reason)); ws.send( JSON.stringify({ category: "request", request_id: "connection", name: "connection.get", data: {}, }), ); }

async onFrame(frame) { if ( frame.category === "result" && frame.request_id === "connection" && frame.data ) { await this.ctx.storage.put("connectionId", frame.data.connection.id); return; } if (frame.category !== "message") return;

switch (frame.name) { case "transcript.received": if (frame.data.is_final) { console.log(${frame.data.speaker.display_name}: ${frame.data.text}); } break; case "participant.join": console.log("joined", frame.data.participant.display_name); break; case "session.ended": console.log("session ended", frame.data.reason); break; } }

async onClose(code, reason) { this.ws = undefined; if (code === 4409 || code === 4403) return; // replaced, or fix the token if (code === 1000 || reason === "queue_overflow") { // Start fresh next time. Call start() again for the next session. await this.ctx.storage.delete("connectionId"); if (code === 1000) return; } this.retry(); }

retry() { // Resume within 5 seconds to avoid a gap. this.ctx.storage.setAlarm(Date.now() + 1000); }

async alarm() { await this.connect(); } } ```

src/agent.tsts

   import { DurableObject } from "cloudflare:workers";

const AGENT_WS = "https://api.realtime.cloudflare.com/agent-ws/v1";
   const SUBSCRIPTIONS = "transcript,participant";

export class MeetingAgent extends DurableObject<Env> {
    ws?: WebSocket;

async start(token: string) {
        await this.ctx.storage.put("token", token);
        await this.ctx.storage.delete("connectionId");
        await this.connect();
    }

async stop() {
        // Closing with 1000 makes the agent leave the meeting immediately.
        await this.ctx.storage.deleteAll();
        await this.ctx.storage.deleteAlarm();
        this.ws?.close(1000, "agent_stopped");
        this.ws = undefined;
    }

async connect() {
        const token = await this.ctx.storage.get<string>("token");
        if (!token || this.ws) return;

const url = new URL(AGENT_WS);
        url.searchParams.set("subscriptions", SUBSCRIPTIONS);
        const connectionId = await this.ctx.storage.get<string>("connectionId");
        if (connectionId) url.searchParams.set("connection_id", connectionId);

// Workers open outbound WebSockets with fetch and an Upgrade header.
        const res = await fetch(url, {
            headers: {
                Upgrade: "websocket",
                Authorization: `Bearer ${token}`,
                "Sec-WebSocket-Protocol": "realtimekit.v1",
            },
        });
        const ws = res.webSocket;
        if (!ws) {
            console.error("upgrade failed", res.status, await res.text());
            if (res.status === 404) await this.ctx.storage.delete("connectionId");
            if (res.status >= 500 || res.status === 404) this.retry();
            return;
        }

ws.accept();
        this.ws = ws;
        ws.addEventListener("message", (e) => this.onFrame(JSON.parse(e.data as string)));
        ws.addEventListener("close", (e) => this.onClose(e.code, e.reason));
        ws.send(
            JSON.stringify({
                category: "request",
                request_id: "connection",
                name: "connection.get",
                data: {},
            }),
        );
    }

async onFrame(frame: any) {
        if (frame.category === "result" && frame.request_id === "connection" && frame.data) {
            await this.ctx.storage.put("connectionId", frame.data.connection.id);
            return;
        }
        if (frame.category !== "message") return;

switch (frame.name) {
            case "transcript.received":
                if (frame.data.is_final) {
                    console.log(`${frame.data.speaker.display_name}: ${frame.data.text}`);
                }
                break;
            case "participant.join":
                console.log("joined", frame.data.participant.display_name);
                break;
            case "session.ended":
                console.log("session ended", frame.data.reason);
                break;
        }
    }

async onClose(code: number, reason: string) {
        this.ws = undefined;
        if (code === 4409 || code === 4403) return; // replaced, or fix the token
        if (code === 1000 || reason === "queue_overflow") {
            // Start fresh next time. Call start() again for the next session.
            await this.ctx.storage.delete("connectionId");
            if (code === 1000) return;
        }
        this.retry();
    }

retry() {
        // Resume within 5 seconds to avoid a gap.
        this.ctx.storage.setAlarm(Date.now() + 1000);
    }

async alarm() {
        await this.connect();
    }
   }
   ```

4. **Deploy and start the agent.**

```sh
   npx wrangler deploy
   curl -X POST https://meeting-agent.<SUBDOMAIN>.workers.dev/meetings/<MEETING_ID>/agent
   ```

Join the meeting from a browser and speak. Run `npx wrangler tail` to see transcripts and joins.

When the meeting is over, remove the agent:

```sh
   curl -X DELETE https://meeting-agent.<SUBDOMAIN>.workers.dev/meetings/<MEETING_ID>/agent
   ```

## Billing and ending the session

The agent is a meeting participant, so it is billed like one. RealtimeKit charges participant minutes for the agent from when it joins a session until it leaves. Refer to [RealtimeKit pricing](https://developers.cloudflare.com/realtime/realtimekit/platform/pricing/) for rates and to the [billing FAQ](https://developers.cloudflare.com/realtime/realtimekit/faq/#billing) for how participant minutes are counted.

A session stays active while any participant is connected, and that includes the agent. If everyone else leaves but the agent stays connected, the session does not end. RealtimeKit keeps billing participant minutes for the agent, and for any [recording](https://developers.cloudflare.com/realtime/realtimekit/recording-guide/) in progress, until the agent leaves or someone ends the session.

Use one of these to end a session:

| Method | Effect |
| --- | --- |
| Close the WebSocket with code `1000` | The agent leaves immediately. The session ends when no other participants remain. |
| Call the [Kick all participants API](https://developers.cloudflare.com/api/resources/realtime_kit/subresources/active-session/methods/kick_all_participants/) | Removes every participant, including the agent, and ends the session. The agent receives `participant.kick_all` and `session.ended`. |

To end the session for everyone, for example when the scheduled meeting time is over, call the Kick all participants API:

<details>

<summary>

Required API token permissions

</summary>

At least one of the following <a href="https://developers.cloudflare.com/fundamentals/api/reference/permissions/">token permissions</a> is required:

- <code>Realtime Admin</code>
- <code>Realtime</code>

</details>

*Kick all participantsbash*

```bash
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/realtime/kit/$APP_ID/meetings/$MEETING_ID/active-session/kick-all" \
    --request POST \
    --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"

Connection reference

Requests

Send { "category": "request", "request_id", "name", "data": {} }. Each request gets one result with the same request_id, and either data or error.

Name Returns
connection.get { connection: { id, subscriptions } }
connection.ping {}
{
    "category": "result",
    "request_id": "r1",
    "name": "connection.get",
    "data": {
        "connection": {
            "id": "conn_6f1c2a7e",
            "subscriptions": ["transcript", "participant"]
        }
    },
    "error": null
}

Failed results have data: null and error.code set to INVALID_REQUEST, UNSUPPORTED_REQUEST, or INTERNAL_ERROR. The connection stays open.

Reconnect

Agent Durable Object

Agent Participant

Meeting

connection.get

connection.id

WebSocket drops with a code other than 1000

Agent stays joined for 5 s and queues events

Upgrade with ?connection_id=

Queued events, in order

Live events

Store connection.id and pass it as connection_id to resume. It identifies the WebSocket connection, not the participant or session.

  • Closing with 1000 makes the agent leave the meeting immediately.
  • Any other close starts a 5-second grace period. The agent stays joined and queues events.
  • Events sent while the agent was out of the meeting are lost. A second session.started marks the gap.
Close code Reason Action
1000 session_ended Open a new connection for the next session
1013 queue_overflow Open a new connection without connection_id
1013 upstream_lost Resume with connection_id
1013 upstream_unavailable Resume with connection_id and backoff
4403 upstream_rejected Fix the token or participant
4409 replaced Do not reconnect
What a resume does Unsent events are always delivered first, in order. | State | Result | | --- | --- | | Within the grace period, or previous WebSocket still open | Same connection continues with no gap. A previous WebSocket closes with 4409. | | Agent already left, or closed with upstream_lost or upstream_unavailable | Agent joins again. You get a new session.started, then live events. | | Closed with session_ended, queue_overflow, or upstream_rejected | HTTP 101, then the same close again. Open a new connection without connection_id. | - If you include subscriptions on resume, it replaces the stored list. Already-queued events are not re-filtered. - A fresh token for the same participant is accepted. A connection_id does not expire. - For a heartbeat, send connection.ping every 20 seconds. Reconnect if no result arrives within 10 seconds.

Errors

A rejected upgrade returns an HTTP error instead of 101:

{
    "success": false,
    "error": {
        "code": "UNAUTHENTICATED",
        "message": "Invalid participant token.",
        "retryable": false
    }
}
Status Code Cause
400 BAD_REQUEST realtimekit.v1 subprotocol missing
400 INVALID_QUERY Unknown parameter or subscription, or empty connection_id
401 UNAUTHENTICATED Missing, invalid, or expired token
404 CONNECTION_NOT_FOUND Unknown connection_id, or another participant's

Limits

Limit Value
Unsent events per connection 10,000
Resume grace period 5 seconds
request_id length 1–128 characters

Send events

Sending chat messages and broadcasts from the agent is planned. Today, the agent is receive-only. To act on the meeting now, call the RealtimeKit REST API from your Worker, for example to kick participants.

Was this helpful?

YesNo

On this page

Build an agent·续 - 图1Docs

{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/realtime/realtimekit/agents/get-started/#page","headline":"Build an agent","description":"Configure an agent preset, add the agent to a RealtimeKit meeting, connect to the Agent's WebSocket URL, and run the agent in a Durable Object.","url":"https://developers.cloudflare.com/realtime/realtimekit/agents/get-started/","inLanguage":"en","image":"https://developers.cloudflare.com/realtime/realtimekit/agents/get-started/og.png?v=c4851f25d2c6bd78","dateModified":"2026-10-26","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}


description: Add AI-powered transcription and summarization to RealtimeKit meetings. title: Meeting AI image: https://developers.cloudflare.com/realtime/realtimekit/ai/og.png?v=69882abb3d1c62a4


Skip to content

Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/realtime/llms.txt
Use this file to discover all available pages before exploring further.