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
1000makes 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.startedmarks 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 with4409. |
| 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
{"@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
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.
Docs