Build an agent

Last updated Oct 26, 2026 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:

<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>

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:

<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>

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:

<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>

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 by default.

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

Note

The transcript subscription only delivers events if real-time transcription is on. Set permissions.transcription_enabled: true in the presets of the participants whose speech you want transcribed. Refer to Turn on real-time transcription.

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-27",
    "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",
        },
    },
   }
   ```

```toml
   name = "meeting-agent"
   main = "src/index.ts"
   # Set this to today's date
   compatibility_date = "2026-10-27"

[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:npmyarnpnpm

npx wrangler secret put CLOUDFLARE_API_TOKEN ```

   yarn wrangler secret put CLOUDFLARE_API_TOKEN
   ```

pnpm wrangler secret put CLOUDFLARE_API_TOKEN ```

  1. 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

```js 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

```ts export { MeetingAgent } from "./agent";

export default { async fetch(request, env): Promise { 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; ```

  1. 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

```js 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; }