Skip to content

Build an agent

Last updated View as MarkdownAgent setup

An agent is a meeting participant that your backend controls over a WebSocket. To add one, configure a preset, add the agent as a participant, and connect to the Websocket endpoint with the agent's participant token.

My Worker
My Durable Object
Agent Participant
RealtimeKit Meeting
Add participant with the agent preset
start(token)
Upgrade with token + subscriptions
Join as visible or hidden participant
session.started
Transcripts, joins, broadcasts
Subscribed events as JSON

Choose an agent pattern

Decide whether people in the meeting should see the agent. Both patterns use the same steps on this page and receive the same events. Only the preset changes.

Pattern Preset Use it for
Hidden agent hidden_participant: true Compliance monitoring, live analytics
Visible agent hidden_participant: false A note-taker people can see in the meeting

Hidden agent

Use a hidden agent when people should not see it, for example to monitor for compliance, review call quality, or collect live meeting analytics.

PeopleBrowser or app with the RealtimeKit SDKs
Audio, video, chat
RealtimeKit meeting
What people see
Alice
Bob
Hidden agentMonitorFiltered out of the list and grid
Transcripts, joins, broadcasts
My codeDurable ObjectWebSocket ↔ JSON
PeopleAudio, video, chat ↔RealtimeKit meeting
MonitorHidden from peopleRealtimeKit meeting
MonitorTranscripts, joins, broadcasts →Durable Object
  • Not shown to people. The RealtimeKit SDKs filter the agent out of the participant list and the video grid.
  • Join and leave events still go out. Every client connected to the meeting receives them, and the SDKs choose not to show them. Do not rely on hidden_participant to keep the agent secret from someone inspecting their own client.
  • Same events and billing. A hidden agent receives every event it subscribes to, is billed as a participant, and keeps the session active while connected.
  • No one removes it for you. People cannot see the agent, so your backend must end it.
  • Disclosure is up to you. Hiding the agent does not hide that the meeting is transcribed. Tell participants if your policy requires it.

Visible agent

Use a visible agent when people should know it is there, for example a note-taker named "Notes bot" that starts taking notes as soon as it joins.

PeopleBrowser or app with the RealtimeKit SDKs
Audio, video, chat
RealtimeKit meeting
What people see
Alice
Bob
Notes bot
Visible agentNotes botShows as a tile, with no audio or video
Transcripts, joins, broadcasts
My codeDurable ObjectWebSocket ↔ JSON
PeopleAudio, video, chat ↔RealtimeKit meeting
Notes botTile in the gridRealtimeKit meeting
Notes botTranscripts, joins, broadcasts →Durable Object
  • Shown by name. The name you pass when you add the agent as a participant is what people see.
  • Takes a tile in the grid. The agent appears in the video grid even though it sends no audio or video. In a custom Core SDK grid, filter it out by participant.presetName.
  • Starts when it joins. Anything said before the agent joins is not captured. Add the agent before the meeting starts to capture the whole session.

Requirements

  • A RealtimeKit app and meeting. Refer to the quickstart.
  • A Cloudflare API token with RealtimeKit access.
  • To receive live transcripts, real-time transcription turned on in the presets of the participants you want transcribed. Without it, the agent receives no transcript.received events.

1. Configure an agent preset

The agent joins the meeting with a preset, like any other participant. You can use an existing preset, but a dedicated agent preset keeps agent settings separate from human participants. Create one in the RealtimeKit dashboard ↗︎ or with the Create a preset API.

To hide the agent from other participants, set hidden_participant with the Update a preset API:

Required API token permissions

At least one of the following token permissions is required:
  • Realtime Admin
  • Realtime
Update a presetbash
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/realtime/kit/$APP_ID/presets/$PRESET_ID" \
	--request PATCH \
	--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
	--json '{
		"permissions": {
				"hidden_participant": true
		}
	}'

hidden_participant changes only what other participants see. The agent receives the same events either way. For what a hidden agent looks like to others, refer to Hidden agent.

2. Create a meeting and a participant token for your agent

If you do not have a meeting yet, create one with the Create a meeting API:

Required API token permissions

At least one of the following token permissions is required:
  • Realtime Admin
  • Realtime
Create a meetingbash
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/realtime/kit/$APP_ID/meetings" \
	--request POST \
	--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
	--json '{
		"title": "Weekly product review"
	}'

The response data.id is the meeting ID. Use it as $MEETING_ID in the next request.

Then add a participant to the meeting with the agent preset:

Required API token permissions

At least one of the following token permissions is required:
  • Realtime Admin
  • Realtime
Add a participantbash
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/realtime/kit/$APP_ID/meetings/$MEETING_ID/participants" \
	--request POST \
	--header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
	--json '{
		"name": "Notes bot",
		"preset_name": "agent",
		"custom_participant_id": "notes-bot"
	}'

The response data.token is the agent's participant token. Reuse the same participant for every session of the meeting. A participant token is valid for 100 days.

3. Connect to the Agent's WebSocket URL

Open a WebSocket to the endpoint with the participant token and the realtimekit.v1 subprotocol:

GET wss://api.realtime.cloudflare.com/agent-ws/v1?subscriptions=transcript,participant,broadcast
Authorization: Bearer <PARTICIPANT_TOKEN>
Sec-WebSocket-Protocol: realtimekit.v1

The token selects the meeting. HTTP 101 means the connection is ready. The agent then joins the meeting and sends session.started.

4. Set connection parameters

Parameter Value
subscriptions Optional. Any of transcript, participant, broadcast, comma-separated.
connection_id Optional. Resumes an earlier connection. Refer to Reconnect.

subscriptions decides which events the agent receives. Session events are always sent.

Category Events Needs
transcript transcript.received Real-time transcription on the speakers' presets
participant participant.join, participant.leave Nothing
broadcast broadcast.received Your app sends broadcasts

Subscribe only to what you use. Fewer events means less queueing during reconnects. For payloads and delivery rules, refer to Events.

5. Run the agent in a Durable Object

This example puts steps 1 to 4 together. A Worker adds the agent to a meeting, and a Durable Object holds the agent's WebSocket and logs final transcripts and participant joins.

A Durable Object fits the agent: one instance per meeting, a long-lived outbound WebSocket, and storage for the resume connection_id.

  1. Configure the Worker. Bind the Durable Object and set the app details:

    {
    	"name": "meeting-agent",
    	"main": "src/index.ts",
    	// Set this to today's date
    	"compatibility_date": "2026-10-19",
    	"vars": {
    		"ACCOUNT_ID": "<ACCOUNT_ID>",
    		"APP_ID": "<APP_ID>",
    		"AGENT_PRESET": "agent",
    	},
    	"durable_objects": {
    		"bindings": [{ "name": "MEETING_AGENT", "class_name": "MeetingAgent" }],
    	},
    	"exports": {
    		"MeetingAgent": {
    			"type": "durable-object",
    			"storage": "sqlite",
    		},
    	},
    }
    name = "meeting-agent"
    main = "src/index.ts"
    # Set this to today's date
    compatibility_date = "2026-10-19"
    
    [vars]
    ACCOUNT_ID = "<ACCOUNT_ID>"
    APP_ID = "<APP_ID>"
    AGENT_PRESET = "agent"
    
    [[durable_objects.bindings]]
    name = "MEETING_AGENT"
    class_name = "MeetingAgent"
    
    [exports.MeetingAgent]
    type = "durable-object"
    storage = "sqlite"

    Store the API token as a secret:

    npx wrangler secret put CLOUDFLARE_API_TOKEN
  2. Add and remove the agent. POST adds a participant with the agent preset and starts the meeting's Durable Object. DELETE removes the agent from the meeting:

    src/index.jsjs
    export { MeetingAgent } from "./agent";
    
    export default {
    	async fetch(request, env) {
    		const url = new URL(request.url);
    		const match = url.pathname.match(/^\/meetings\/([^/]+)\/agent$/);
    		if (!match) return new Response("Not found", { status: 404 });
    		const meetingId = match[1];
    		const agent = env.MEETING_AGENT.getByName(meetingId);
    
    		// Authenticate the caller before adding or removing an agent.
    
    		if (request.method === "DELETE") {
    			await agent.stop();
    			return new Response("Agent stopped", { status: 200 });
    		}
    		if (request.method !== "POST") {
    			return new Response("Method not allowed", { status: 405 });
    		}
    
    		const res = await fetch(
    			`https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}/realtime/kit/${env.APP_ID}/meetings/${meetingId}/participants`,
    			{
    				method: "POST",
    				headers: {
    					Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
    					"Content-Type": "application/json",
    				},
    				body: JSON.stringify({
    					name: "Notes bot",
    					preset_name: env.AGENT_PRESET,
    					custom_participant_id: `agent-${meetingId}`,
    				}),
    			},
    		);
    		if (!res.ok) return new Response("Add participant failed", { status: 502 });
    		const { data } = await res.json();
    
    		await agent.start(data.token);
    		return new Response("Agent started", { status: 202 });
    	},
    };
    src/index.tsts
    export { MeetingAgent } from "./agent";
    
    export default {
    	async fetch(request, env): Promise<Response> {
    		const url = new URL(request.url);
    		const match = url.pathname.match(/^\/meetings\/([^/]+)\/agent$/);
    		if (!match) return new Response("Not found", { status: 404 });
    		const meetingId = match[1];
    		const agent = env.MEETING_AGENT.getByName(meetingId);
    
    		// Authenticate the caller before adding or removing an agent.
    
    		if (request.method === "DELETE") {
    			await agent.stop();
    			return new Response("Agent stopped", { status: 200 });
    		}
    		if (request.method !== "POST") {
    			return new Response("Method not allowed", { status: 405 });
    		}
    
    		const res = await fetch(
    			`https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}/realtime/kit/${env.APP_ID}/meetings/${meetingId}/participants`,
    			{
    				method: "POST",
    				headers: {
    					Authorization: `Bearer ${env.CLOUDFLARE_API_TOKEN}`,
    					"Content-Type": "application/json",
    				},
    				body: JSON.stringify({
    					name: "Notes bot",
    					preset_name: env.AGENT_PRESET,
    					custom_participant_id: `agent-${meetingId}`,
    				}),
    			},
    		);
    		if (!res.ok) return new Response("Add participant failed", { status: 502 });
    		const { data } = await res.json<{ data: { token: string } }>();
    
    		await agent.start(data.token);
    		return new Response("Agent started", { status: 202 });
    	},
    } satisfies ExportedHandler<Env>;
  3. Establish the WebSocket in a Durable Object. The Durable Object connects, stores connection_id, handles events, reconnects, and leaves the meeting on stop():

    src/agent.jsjs
    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 {
    	ws;
    
    	async start(token) {
    		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("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.

    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:

    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 for rates and to the billing FAQ 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 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 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:

Required API token permissions

At least one of the following token permissions is required:
  • Realtime Admin
  • Realtime
Kick all participantsbash
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?