Agent concept
v1's surface for working with AI agents β participants that join the room as autonomous runtimes. From the client you can spawn an agent declaratively, identify it, read its state, stream its transcript and metrics, steer it at runtime (say/reply/interrupt), and expose tools it can call via RPC.
Agents are participants β they fire participant-joined, publish audio, can be subscribed to. startAgent returns an AgentParticipant: a narrowed subtype of RemoteParticipant (not a parallel Agent class) that adds control + lifecycle methods on top of kind, agentState, agent-text, and RPC.
meeting.startAgent(config) you name the prompt + providers/models inline and VideoSDK's cloud runs a generic agent runtime β no backend, Python worker, or per-agent deploy. Only provider secrets live in the dashboard (BYOK), or you use the 'videosdk' inference gateway and bring no keys at all. The runtime internals (turn loop, plugin wiring) are managed; dispatch and configuration are yours.The 7 surfaces
| Surface | Lives on | What it does |
|---|---|---|
meeting.startAgent | Meeting (factory) | Config-driven (or custom-agentId) dispatch: spawn an agent from the client. Pipeline mode derived from the config. |
AgentParticipant β say/reply/interrupt + update/stop | Agent participant | Steer and reconfigure the running agent. Speak verbatim, ask the LLM to reply, cut it off, live-reconfigure, or stop it. |
participant.kind | Every participant (Local + Remote) | Discriminator: 'human' \| 'agent'. Server-set from how the participant connected. |
participant.agentState + agent-state-changed | Every participant (null when not an agent) | Conversational phase: idle / listening / thinking / speaking / failed. |
agent-text event | Room | Streams transcripts from agents β both transcribed user speech and agent-generated text. |
agent-metric event | Agent participant | Lazy per-metric stream mirroring the runtime's exact fields (llm_ttft, ttfb, latencies, tokens, β¦), tagged by component. |
RPC β registerRpc / callRpc | Local + Remote participants | Directed request β response. The executor behind LLM tool calls. |
Spawning an agent β meeting.startAgent
Spawn an agent from the client in one of two forms: config-driven (pass an AgentConfig β VideoSDK runs a generic runtime built from it) or custom-agent dispatch (pass an AgentDispatch with { agentId } β dispatch a pre-deployed custom Python agent). Both return an AgentParticipant and are gated by the allow_agent token claim.
AgentConfig interface
Two forms β config vs agentId
The form is chosen by a discriminator: presence of agentId β custom-agent dispatch; otherwise β config-driven. They're mutually exclusive (agentId alongside pipeline fields β INVALID_AGENT_CONFIG). They differ in what the client controls, because an agentId agent owns its own pipeline and lifecycle:
| Capability | Config-driven (AgentConfig) | Custom dispatch ({ agentId }) |
|---|---|---|
| Spawn / stop | β | β
(wraps POST /v2/agent/dispatch) |
| Pipeline / providers | declared inline | owned by the deployed agent β not in the call |
agent.update() (live reconfigure) | β | β β only metadata at dispatch |
enterMessage / exitMessage | β | β β the agent's own on_enter/on_exit |
| Tools | schema in tools[] | agent-side @function_tool |
agent.mode | derived from config | reported if known, else undefined |
say / reply / interrupt | β guaranteed | best-effort β only if the agent exposes control handlers |
AgentDispatch interface
A thin, faithful pass-through to the existing dispatch API for a pre-deployed custom (Python) agent β for logic config-driven can't express (custom flows, multi-agent handoff, RAG, MCP).
const agent = await meeting.startAgent({
agentId: 'support-bot-v3',
metadata: { customerId: 'c_123', tier: 'premium' }, // β dispatch metadata.variables
});
Pipeline mode β derived, not declared (config-driven)
One llm slot holds either a text LLM or a realtime (speech-to-speech) model β the runtime resolves which from the model identifier. The mode falls out of which components are present, exactly as the Python Pipeline computes it. No realtime flag, no separate object.
| Config | Derived mode |
|---|---|
text llm + stt + tts | cascade |
realtime llm, no stt/tts | realtime (full speech-to-speech) |
realtime llm + tts | hybrid_tts β realtime brain + your voice |
realtime llm + stt | hybrid_stt β your ears + realtime brain/voice |
voice in the llm block applies when the model resolves to realtime (mirrors GeminiLiveConfig(voice=β¦)); for text LLMs the voice is the tts. The resolved mode is readable on the returned participant as agent.mode.
vad and turnDetector expose only the common threshold knob in v1. The SDK's other Silero tuning params (start_threshold, min_speech_duration, min_silence_duration, padding_duration, β¦) use runtime defaults and are deferred β a deliberate simplification, not a different surface.Providers & credentials
provider is required on every component (no silent default β no surprise billing). Keys never appear in the browser.
provider | Path | Key | Billed |
|---|---|---|---|
'videosdk' | inference gateway | none β uses auth token | org's VideoSDK balance |
'openai', 'deepgram', 'elevenlabs', β¦ | BYOK | dashboard-stored, referenced by name | the provider directly |
'videosdk' gateway voice is just two independent resolutions. A named provider with no stored key β MISSING_PROVIDER_CREDENTIAL.avatar: { provider: 'simli' | 'anam', avatarId }) renders a talking face from the agent's speech and is published as the agent participant's own camera + mic β so there's one agent participant and nothing new on the client: subscribe to the agent's video on stream-published as usual; agentState/agent-text/say/interrupt stay on that agent. Avatar keys are dashboard-stored (BYOK) β 'videosdk' doesn't broker avatars. (A separate self-hosted "Avatar Server" that joins as its own participant exists but is out of scope for v1.)AgentTool interface
A function-tool's schema travels in the config (there's no Python agent to derive it from β the config is the agent-side definition). The executor is a registered RPC handler with the same name. See Β§Function tools for the full wiring.
const me = room.localParticipant;
// Executor β a normal RPC handler (runs in the browser)
me.registerRpc('book_appointment', async (req) => myApp.book(req.payload.date));
const agent = await meeting.startAgent({
instructions: 'Help the user book appointments.',
enterMessage: 'Hi! How can I help you today?',
exitMessage: 'Thanks, goodbye!',
stt: { provider: 'deepgram', model: 'nova-3', language: 'en' },
llm: { provider: 'openai', model: 'gpt-4o', temperature: 0.7 },
tts: { provider: 'elevenlabs', voice: '21m00...' },
tools: [{
name: 'book_appointment',
description: 'Book an appointment for the user',
parameters: { type: 'object', properties: { date: { type: 'string' } }, required: ['date'] },
}],
});
agent.mode; // 'cascade'
// Pure realtime (speech-to-speech)
await meeting.startAgent({
instructions: '...',
llm: { provider: 'videosdk', model: 'gemini-live', voice: 'Leda' }, // β mode 'realtime'
});
// Hybrid β realtime brain + custom voice
await meeting.startAgent({
instructions: '...',
llm: { provider: 'videosdk', model: 'gemini-live' },
tts: { provider: 'elevenlabs', voice: '21m00...' }, // β mode 'hybrid_tts'
});
Lifecycle
Only startAgent is on meeting (the factory β the object doesn't exist yet). update/stop live on the returned AgentParticipant (also reachable via meeting.participants.get(id)), alongside say/reply/interrupt.
| Call | Behavior |
|---|---|
agent.update(partial) | Full live reconfiguration β including provider swap and cascadeβrealtime mode switch (mirrors Python change_component; preserves context + tool-call history). Config-driven agents only. |
agent.stop() | Graceful β speaks exitMessage, then leaves. |
agent.stop({ force: true }) | Immediate teardown; skips exitMessage. |
startAgent and the participant's update() / stop() (and say/reply/interrupt) require the allow_agent token claim (parallel to allow_join). Without it they reject with INVALID_PERMISSIONS. See AGENT_DECISIONS_V1.md Decision 7.Steering the agent β AgentParticipant
startAgent returns an AgentParticipant β a narrowed subtype of RemoteParticipant (it appears in room.remoteParticipants, can be subscribed to, inherits everything). On top it adds runtime control + lifecycle. Also reachable via room.remoteParticipants.get(id) where kind === 'agent'.
| Method | What it does |
|---|---|
say(message) | Speak the string verbatim through TTS β disclaimers, hold messages, scripted lines. addToContext: false speaks without adding to chat history. |
reply(instructions) | The LLM generates a contextual response from the instructions + conversation. Non-deterministic phrasing. |
interrupt() | Stop the agent's current utterance. Always honored (explicit app command, no force flag) β interruptible: false only blocks user barge-in. |
const agent = meeting.participants.get(agentId); // kind === 'agent'
// Wait for the disclaimer to finish before enabling the mic
await agent.say('Your call is being recorded.'); // promise resolves on playback completion
mic.enable();
// Let the LLM phrase a contextual prompt
await agent.reply('Ask the user to confirm their email address');
// Cut it off if the user starts talking
agent.interrupt();
wait_for_playback β awaiting the promise. The Python SDK's say/reply take a wait_for_playback flag. We don't expose it β say/reply return a Promise<void> that resolves on playback completion, so plain await-or-don't expresses every mode (the promise is the completion signal, no handle needed). A waitForPlayback boolean was considered and rejected: it would change when the same promise resolves (start vs completion) β a two-meanings-one-type knob β and await-or-don't already covers what matters. (The SDK's frames param β video frames for vision β maps to the deferred multimodal feature, not v1.)wait_for_playback modes, via the promiseawait agent.reply('Summarize'); // wait_for_playback: true β sequence after speech
agent.reply('Summarize').catch(onErr); // wait_for_playback: false β fire-and-forget while it speaks
const p = agent.reply('Summarize'); // start nowβ¦
doWork(); // β¦continue while it speaksβ¦
await p; // β¦then block until done (Python needs a handle for this)
AgentParticipant is a RemoteParticipant β tile rendering, subscribe(), and stream handling all work on it unchanged. It only adds control + lifecycle methods and narrows kind. Control is gated by allow_agent and transported over RPC. See Decision 8.Identifying an agent β participant.kind
A readonly property on every participant. Set by the server based on how that participant connected β clients can't spoof it, and the token doesn't carry it.
| Value | What it means |
|---|---|
'human' | Standard user participant. The default for anyone connecting via VideoSDK.join(). |
'agent' | An AI agent β whether config-dispatched or a custom worker; the server tags the participant accordingly. |
'human' \| 'agent' \| 'sip' \| 'recorder' \| ...). Apps that branch on kind should always include a default clause.Join / leave β reuses existing events
Agents fire the existing participant-joined and participant-left events. There is no agent-joined / agent-left β apps discriminate inside the handler.
kind inside the join handlerroom.on('participant-joined', (p) => {
if (p.kind === 'agent') {
ui.showAgentTile(p);
// Register tools you want the agent to call (see Β§RPC below)
// Subscribe to its state and transcript
} else {
ui.showHumanTile(p);
}
});
room.on('participant-left', (p) => {
// SDK auto-cleans listeners on the leaving participant β kind doesn't matter here
ui.removeTile(p.id);
});
Agent state
A 5-value state machine surfaced as both a synchronous property and a transition event. The property is null for non-agent participants.
| State | Meaning |
|---|---|
idle | Agent is in the room but not actively in conversation. UI: "Ready." |
listening | Agent is receiving / processing user audio. UI: mic indicator on agent's tile. |
thinking | LLM is generating a response. UI: spinner / thinking indicator. |
speaking | Agent is producing audio response. UI: speaking indicator. |
failed | Terminal β agent encountered an error. error is set on the event payload; p.agentFailure persists on the participant. |
Failure payload
When agentState === 'failed', the transition event carries an SDKError in error. Same shape as every other failure in v1:
| Initial code | When | Retriable |
|---|---|---|
AGENT_INTERNAL_ERROR | Agent runtime crash / unhandled exception (generic β message carries the detail) | true |
AGENT_TIMEOUT | LLM / TTS / STT call exceeded its budget; the agent gave up | true |
room.on('participant-joined', (agent) => {
if (agent.kind !== 'agent') return;
// Initial sync render β agentState is readable immediately
ui.setAgentState(agent.id, agent.agentState); // probably 'idle' or 'listening'
// Animate transitions
agent.on('agent-state-changed', ({ state, prev, error }) => {
ui.setAgentState(agent.id, state);
if (state === 'failed') {
console.error('Agent failed:', error.code, error.message);
if (error.retriable) ui.showRetryButton();
else ui.showFatalError(error.message);
}
});
});
Transcripts β the agent-text event
Fires on the Room whenever an agent in the room produces text β both transcribed user speech AND agent-generated text. Separate from the room-wide transcription-text event (which fires only when room.startTranscription() is active). Same payload shape, distinct producer.
| Field | Type | Description |
|---|---|---|
participantId | string | Who the text represents. User-speech transcription: the user's id. Agent-generated text: the agent's id. |
text | string | The transcribed / generated text content. |
isFinal | boolean | false for interim / streaming chunks. true for the committed final segment. |
timestamp | Date | When the text was produced. |
Text flows on agent-text
| Scenario | participantId | text |
|---|---|---|
| User says "what's the weather?"; agent's STT transcribes it | user's id | "what's the weather?" |
| Agent's LLM generates "It's 72Β°F and sunny" | agent's id | "It's 72Β°F and sunny" |
| Streaming LLM tokens | agent's id | each chunk fires isFinal: false; completion fires isFinal: true |
transcription-text. If both room.startTranscription() and an agent run simultaneously, both transcribe the same user speech. Two events with distinct ownership avoid same-event duplicate fire. transcription-text = "the room's transcription service produced this"; agent-text = "an agent in the room produced this." Apps subscribe to whichever they need. See AGENT_DECISIONS_V1.md Decision 4.room.on('agent-text', ({ participantId, text, isFinal, timestamp }) => {
const p = room.remoteParticipants.get(participantId) ?? room.localParticipant;
const role = p?.kind === 'agent' ? 'assistant' : 'user';
if (!isFinal) {
ui.updateStreamingMessage(role, text); // overwrite the in-progress line
} else {
ui.commitMessage(role, text, timestamp); // append final to history
}
});
RPC β bidirectional tool calls
Directed request β response between participants. The local participant registers methods others can call; remote participants call methods the other end registered. The canonical agent pattern β when the agent's LLM emits a tool call, the runtime translates it into p.callRpc(...), gets the result, and feeds it back to the LLM.
RPC is not agent-specific β it's a general primitive. Moderator confirmations, distributed state queries, and app-level negotiation flows all use it.
Register on the local participant
Call on the remote participant
RpcRequest interface
| Limit | Value |
|---|---|
| Method name | β€ 64 characters |
| Payload (request + response, JSON-encoded) | β€ 32 KiB |
| Timeout | default 10 s, max 60 s |
| Re-register same method | rejects with RPC_METHOD_ALREADY_REGISTERED |
| Broadcast | Not supported β iterate per participant with Promise.allSettled |
room) because calls are directed β every call has one recipient. Same pattern as p.subscribe(), p.requestPublishVideo(), p.remove(). Pubsub is on room because topics are room-wide broadcasts; RPC is on participants because calls aren't.canPublishData AND canSubscribeData on their tokens. Calls reject with INVALID_PERMISSIONS otherwise.Function tools β the canonical LLM-tool-calling pattern
A tool is two things: a schema (name + description + parameters) the LLM reads to decide when/with-what to call, and an executor that runs and returns a result. In v1 the executor is always an RPC handler β there is no separate Tool abstraction; anything you'd call a "tool" is an RPC method registered with me.registerRpc. Where the schema lives depends on how the agent was spawned:
| Spawn path | Tool schema lives | Executor |
|---|---|---|
| Custom Python agent (BYO worker / Agent Cloud deploy) | agent-side (@function_tool auto-derives it from docstring + types) | client RPC handler |
Config-driven dispatch (startAgent) | in the startAgent config β tools: [{ name, description, parameters }] (no Python to derive from; the config is the agent definition) | client RPC handler |
The wiring (config-driven dispatch)
- Client β registers the executor:
me.registerRpc('lookup_weather', async (req) => { β¦ }) - Client β declares the schema:
startAgent({ tools: [{ name: 'lookup_weather', description, parameters }] }) - LLM β decides to invoke a tool and emits a tool-call:
{ tool: 'lookup_weather', args: { city: 'SF' } }. - Runtime β translates it into
callRpc('lookup_weather', args)against the executor participant (the dispatcher by default; overridable per-tool viatools[].executor). - Client handler β runs, returns the result.
- Runtime β feeds the result back to the LLM as a tool-result; the LLM continues.
name is the contract. For config-dispatched agents the schema travels in the tools array as JSON Schema; for custom Python agents it lives agent-side in whatever format the LLM expects (OpenAI / Anthropic / framework-specific). Either way the SDK provides only the wire (RPC) β there is no registerTool API. A tool's name must equal the registered RPC method name; mismatch rejects with RPC_METHOD_NOT_FOUND. Treat tool names as your clientβagent API surface and version them carefully.What tools inherit from RPC
| Tool need | How RPC provides it |
|---|---|
| Named dispatch | RPC method name ('lookup_weather' etc.) |
| Structured arguments | payload: unknown β JSON-serializable (matches OpenAI / Anthropic function-call argument shapes natively) |
| Return value to LLM | Promise resolution; agent runtime forwards as tool-result |
| Errors visible to LLM | RPC_HANDLER_ERROR with details β agent runtime can forward to LLM as a tool-error so the LLM can react / retry / apologize |
| Timeout (LLM tool calls shouldn't hang) | opts.timeout (default 10 s) |
| Cancellation (user leaves mid-tool) | req.signal β handler aborts long-running work |
| Size limit (avoid LLM-context blow-out) | 32 KiB payload β fits typical tool args / results; reference oversized results by URL |
What's not in v1's tool surface
| Feature | Why not |
|---|---|
Dedicated me.registerTool({ name, schema, handler }) API | Would just be RPC + schema metadata; schema is data that lives in the config (or agent-side), not a second registration API. One way to register beats two. |
| Built-in JSON-Schema declaration in the SDK | Customers pick their own schema format. We don't impose one. |
| Tool discovery protocol (agent asks the client "what tools do you have?") | The customer writes both halves (client + agent) and knows what's available. For plug-and-play platforms that genuinely need runtime discovery, build it in one RPC method: me.registerRpc('__list_tools', () => mySchemas) β agent calls that at startup. |
| Per-tool permission gating | Use grants for app-wide gating (canPublishData covers all RPC). Per-tool / per-call gating is app-level β implement inside the handler (if (!user.canBook()) return { error: 'denied' };). |
| Streaming tool results (chunked return) | LLM output streams through agent-text. Tool results are one-shot RPC returns. Streaming tool-results is a v1.x consideration if real use cases emerge. |
// Three function tools the agent's LLM can invoke
me.registerRpc('current_location', async () => ({ lat, lng }));
me.registerRpc('upcoming_events', async (req) => ({ events: await calendar.list(req.payload.range) }));
me.registerRpc('book_appointment', async (req) => {
if (!(await ui.confirmBooking(req.payload))) return { booked: false, reason: 'user_declined' };
const r = await calendar.book(req.payload);
return { booked: true, id: r.id };
});
// Tear down at unmount / leave
function cleanup() {
me.unregisterRpc('current_location');
me.unregisterRpc('upcoming_events');
me.unregisterRpc('book_appointment');
}
Metrics β the agent-metric event
Per-metric observability stream emitted by the agent. Fires on the agent participant. Lazy β no upstream traffic unless an app subscribes. The payload mirrors the runtime's exact field names (snake_case, verbatim) and tags each metric with its component; custom names are allowed for agent-specific data.
AgentMetric interface
Vocabulary β exact mirror of the runtime
agent-metric is a transparent pass-through of the runtime's metrics: name is the runtime field name verbatim (snake_case, no translation), component is the runtime's component tag. So there's zero ambiguity mapping a client metric back to what the runtime measured.
| component | name (verbatim) | Unit | Meaning |
|---|---|---|---|
stt | stt_latency | ms | STT recognition time for the turn |
stt | stt_confidence | ratio | 0..1 confidence on the user's transcribed speech |
eou | eou_latency | ms | End-of-utterance detection time |
eou | eou_probability | ratio | Turn-detection confidence |
eou | eou_wait_ms | ms | Time waited for additional speech before committing |
llm | llm_ttft | ms | Time-to-first-token β LLM first output after user stops speaking |
llm | llm_latency | ms | LLM total time (first token β done) |
llm | prompt_tokens | tokens | Prompt tokens consumed this turn |
llm | completion_tokens | tokens | Completion tokens emitted this turn |
llm | total_tokens | tokens | Prompt + completion |
llm | tokens_per_second | tokens/s | LLM generation throughput |
tts | ttfb | ms | TTS time-to-first-byte after LLM tokens start |
tts | tts_latency | ms | TTS synthesis time for the turn |
tts | tts_characters | count | Characters synthesized |
realtime | realtime_ttfb | ms | Realtime model time-to-first-byte |
realtime | realtime_input_tokens | tokens | Realtime input tokens |
realtime | realtime_output_tokens | tokens | Realtime output tokens |
realtime | realtime_total_tokens | tokens | Realtime input + output |
turn | e2e_latency | ms | End-to-end response latency (stt + eou + llm_ttft + tts, or realtime_ttfb) |
stt/eou/llm/tts rows; realtime emits only the realtime rows + turn. Absence of a component means it isn't in the pipeline β not "missing data."'mybot_vector_search_ms') so they don't collide with runtime field names.// Build a small per-turn dashboard
const turnMetrics = new Map(); // turnId β { llm_ttft, llm_latency, ... }
agent.on('agent-metric', (m) => {
if (!m.turn) return; // skip session-level metrics for this UI
const acc = turnMetrics.get(m.turn) ?? {};
acc[m.name] = m.value;
turnMetrics.set(m.turn, acc);
// When the turn finishes (e2e_latency arrives on the 'turn' component), render
if (m.component === 'turn' && m.name === 'e2e_latency') {
ui.showTurnBadge(m.turn, {
ttft: acc.llm_ttft != null ? `${acc.llm_ttft} ms` : 'β',
llmLatency: acc.llm_latency != null ? `${acc.llm_latency} ms` : 'β',
total: `${m.value} ms`,
});
turnMetrics.delete(m.turn);
}
});
let sessionTokensIn = 0, sessionTokensOut = 0;
agent.on('agent-metric', (m) => {
if (m.name === 'prompt_tokens') sessionTokensIn += m.value;
if (m.name === 'completion_tokens') sessionTokensOut += m.value;
if (m.component === 'turn' && m.name === 'e2e_latency') {
ui.updateCostBadge({
tokensIn: sessionTokensIn,
tokensOut: sessionTokensOut,
// metadata may carry provider-specific cost data
model: m.metadata?.model ?? 'unknown',
});
}
});
const SLOW_TTFT_MS = 1500;
agent.on('agent-metric', (m) => {
if (m.name === 'llm_ttft' && m.value > SLOW_TTFT_MS) {
console.warn(`[agent] slow TTFT: ${m.value}ms turn=${m.turn} model=${m.metadata?.model}`);
if (process.env.NODE_ENV === 'development') ui.showDevToast(`Slow TTFT (${m.value}ms)`);
}
});
Examples
1 Β· Agent tool calling β the canonical pattern
An LLM agent decides to look up the user's location to answer "what's the weather?". The client exposes location as an RPC tool; the agent invokes it; the LLM uses the result.
const me = room.localParticipant;
// Tool: get the user's current location (browser-only data)
me.registerRpc('current_location', async () => {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject)
);
return { lat: pos.coords.latitude, lng: pos.coords.longitude };
});
// Tool: query the user's calendar (client-only auth context)
me.registerRpc('upcoming_events', async (req) => {
const { range } = req.payload; // { range: 'today' | 'week' }
const events = await myCalendarAPI.list(range);
return { events };
});
// Tool: book an appointment (needs explicit user confirmation)
me.registerRpc('book_appointment', async (req) => {
const { date, time, title } = req.payload;
const ok = await ui.confirmBooking({ date, time, title });
if (!ok) return { booked: false, reason: 'user_declined' };
const result = await myCalendarAPI.book({ date, time, title });
return { booked: true, id: result.id };
});
The agent's runtime wraps callRpc so LLM function calls "just work" β the agent calls target.callRpc('current_location'), gets the result, and feeds it back to the LLM context. The client doesn't have to know about LLMs at all; it just exposes typed tools.
2 Β· Moderator confirmation β non-agent RPC
Instead of force-unmuting a participant, the host asks via RPC. The host learns the actual outcome (allowed / declined / timed out).
// Participant exposes a confirmation handler
me.registerRpc('confirm_unmute', async (req) => {
const choice = await ui.showDialog({
title: 'Host is asking to unmute you',
body: `${req.from} would like you to speak.`,
actions: ['Allow', 'Deny'],
});
return { allowed: choice === 'Allow' };
});
// Host side
async function inviteToSpeak(participantId) {
const target = room.remoteParticipants.get(participantId);
try {
const { allowed } = await target.callRpc('confirm_unmute', undefined, { timeout: 30_000 });
if (allowed) await target.requestPublishAudio();
else ui.toast(`${target.displayName} declined`);
} catch (err) {
if (err.code === 'RPC_TIMEOUT') ui.toast(`${target.displayName} didn't respond`);
}
}
3 Β· Distributed query across participants
A host runs an attention check before starting a presentation.
me.registerRpc('attention_check', async () => ({
tabFocused: !document.hidden,
lastInteraction: Date.now() - lastUserActionAt,
}));
const results = await Promise.allSettled(
[...room.remoteParticipants.values()]
.filter(p => p.kind === 'human')
.map(async p => {
const data = await p.callRpc('attention_check', undefined, { timeout: 2_000 });
return { id: p.id, name: p.displayName, ...data };
})
);
const focused = results.filter(r => r.status === 'fulfilled' && r.value.tabFocused).length;
const distracted = results.filter(r => r.status === 'fulfilled' && !r.value.tabFocused).length;
const noReply = results.filter(r => r.status === 'rejected').length;
ui.showAttentionBar({ focused, distracted, noReply });
4 Β· Streaming LLM response via agent-text
RPC returns one response. For streaming LLM output (token-by-token), use the agent-text event β interim chunks fire with isFinal: false, completion fires isFinal: true.
let inProgressMessageId = null;
room.on('agent-text', ({ participantId, text, isFinal }) => {
const p = room.remoteParticipants.get(participantId);
if (p?.kind !== 'agent') return;
if (!isFinal) {
// Streaming chunk β append to the in-progress message
inProgressMessageId ??= ui.createStreamingMessage(p.displayName);
ui.appendTo(inProgressMessageId, text);
} else {
// Final β commit and reset
ui.finalizeMessage(inProgressMessageId, text);
inProgressMessageId = null;
}
});
5 Β· Multi-agent room β interpreter + assistant
More than one agent in the same room. Each has its own id, kind: 'agent', and independent state machine. Discriminate by id where needed.
const agents = new Map(); // id β agent participant
room.on('participant-joined', (p) => {
if (p.kind !== 'agent') return;
agents.set(p.id, p);
p.on('agent-state-changed', ({ state }) => {
ui.setAgentState(p.id, state); // independent per agent
});
});
room.on('participant-left', (p) => {
agents.delete(p.id);
});
room.on('agent-text', ({ participantId, text, isFinal }) => {
// participantId tells you WHO the text represents.
// To know which agent PRODUCED it (when text represents a human), the agents
// themselves should publish a per-agent attribute or use a routing convention.
ui.appendTranscript(participantId, text, isFinal);
});
Errors
All agent-related errors surface as SDKError. Catch the class with err.kind === ErrorKind.Server; branch on err.code if you need specifics.
State failures (delivered on agent-state-changed when state === 'failed')
| Code | Kind | Retriable | When |
|---|---|---|---|
AGENT_INTERNAL_ERROR | Server | true | Agent runtime crash / unhandled exception (generic) |
AGENT_TIMEOUT | Server | true | LLM / TTS / STT call exceeded its budget |
Dispatch failures (rejected by meeting.startAgent)
| Code | Kind | Retriable | When |
|---|---|---|---|
INVALID_AGENT_CONFIG | Config | false | No llm; cascade missing stt/tts; or agentId mixed with pipeline fields |
UNKNOWN_MODEL | Config | false | provider+model doesn't resolve to a runtime plugin |
MISSING_PROVIDER_CREDENTIAL | Config | false | Named (BYOK) provider has no dashboard key |
INVALID_PERMISSIONS | Auth | false | Token lacks the allow_agent claim |
AGENT_DISPATCH_FAILED | Server | true | Cloud couldn't allocate a runtime |
RPC failures (delivered as Promise rejection of p.callRpc)
| Code | Kind | Retriable | When |
|---|---|---|---|
RPC_METHOD_NOT_FOUND | Server | false | Recipient didn't register this method (or unregistered it) |
RPC_TIMEOUT | Server | true | No response within timeout ms |
RPC_RECIPIENT_NOT_FOUND | Server | false | Target participant left between call and ack |
RPC_PAYLOAD_TOO_LARGE | Server | false | Request or response exceeds 32 KiB |
RPC_HANDLER_ERROR | Server | false | Recipient's handler threw β error message forwarded in details |
RPC_METHOD_ALREADY_REGISTERED | Server | false | registerRpc called twice for the same name without unregisterRpc |
INVALID_PERMISSIONS | Auth | false | Caller or callee lacks canPublishData / canSubscribeData |
AbortError | β | β | Caller called signal.abort() β DOM standard, not SDKError |
Coverage vs the Python Agents SDK
What the v1 client surfaces, and the Python-SDK features it deliberately does not (yet). The client mirrors the observable + dispatchable surface; server-side runtime concerns and telephony/advanced flows are out.
Covered
| Python SDK | v1 client |
|---|---|
Agent(instructions=β¦) | AgentConfig.instructions |
Pipeline(stt/llm/tts/vad/turn_detector) | config components; mode derived |
Realtime + hybrid (RealtimeMode) | agent.mode (cascade/realtime/hybrid_stt/hybrid_tts) |
InterruptConfig / EOUConfig | interruptConfig / eouConfig |
avatar= (Simli/Anam) | AgentConfig.avatar (agent's own track) |
@function_tool | tools[] schema + RPC executor |
AgentState / transcripts / metrics | agentState Β· agent-text Β· agent-metric (exact mirror) |
say / reply / interrupt | AgentParticipant runtime control |
on_enter / on_exit | enterMessage / exitMessage (declarative) |
Dispatch (config + pre-deployed agentId) | meeting.startAgent (two forms) |
videosdk-inference + BYOK | provider: 'videosdk' / dashboard BYOK |
Not covered
| Python SDK feature | v1 status | Why |
|---|---|---|
Provider fallback (FallbackSTT/LLM/TTS) | βΈ Deferred | error + latency failover; needs provider-array config |
De-noise (denoise) | βΈ Deferred | pipeline component |
Knowledge base / RAG (KnowledgeBase) | βΈ Deferred | retrieval grounding |
| Memory (mem0) | βΈ Deferred | cross-session memory |
MCP (MCPServerHTTP/Stdio) | βΈ Deferred | server-side tool servers |
Vision / multimodality (ImageContent, frames) | βΈ Deferred | video frames β LLM |
| Background / thinking audio | βΈ Deferred | play_background_audio |
Context window / history (get_context_history, seeding) | βΈ Deferred | read / seed conversation history |
userState (user_state_changed) | βΈ Deferred | agent's view of the human |
Multi-agent handoff (AgentHandoff) | β Not in v1 | one agent per dispatch |
A2A (A2AMessage, AgentCard) | β Not in v1 | inter-agent protocol |
| Conversational graph | β Not in v1 | server-side structured flow |
| Preemptive response | β Not in v1 | server latency optimization |
| Call transfer / warm transfer | β Not in v1 | telephony |
DTMF events (DTMFHandler) | β Not in v1 | telephony keypad |
Voicemail detection (VoiceMailDetector) | β Not in v1 | telephony |
TTS caching (TTSAudioCache) | π₯ Server-side | runtime optimization, invisible to client |
| Pipeline hooks / observability / tracing | π₯ Server-side | client gets agent-metric |
| Worker / Agent Cloud / deployment | π₯ Server-side | dispatch surfaced via startAgent |
| Avatar Server (separate-participant topology) | π₯ Server-side | v1 avatar = the agent's own track |
Recording (RecordingOptions) | β Elsewhere | room-level recording API |
| PubSub messaging | β Elsewhere | SDK PubSub (not agent-specific) |
βΈ likely v1.x Β· β no near-term plan Β· π₯ server-side, not a client concern Β· β covered by a non-agent API.
What's NOT in v1
API-shape items deliberately out of scope (SDK-feature gaps are in the coverage table above).
| Feature | Status | Why / where |
|---|---|---|
Binary RPC payloads (Uint8Array) | βΈ Deferred | String / object only in v1; base64-wrap if needed. |
| Broadcast RPC | βΈ Deferred | Iterate per participant with Promise.allSettled in v1. |
| Streaming RPC responses | βΈ Deferred | The canonical agent streaming pattern (LLM tokens) uses agent-text; RPC stays request β one-response. |
Parallel Agent class hierarchy | β Not planned | Agents are participants. AgentParticipant is a narrowed subtype of RemoteParticipant (inherits everything, adds control) β not a parallel tree beside Participant. |
| Agent runtime internals (turn loop, plugin wiring, model hosting) | β Cloud-managed | Config & dispatch are in scope (Decision 7); the runtime that executes the pipeline is VideoSDK-cloud-managed, not a client concern. |
agent-joined / agent-left dedicated events | β Not planned | Branch on p.kind === 'agent' inside participant-joined / -left. See Decision 2. |
See also:
AGENT_DECISIONS_V1.md
Room β participant-joined
Room β participant-left
Room β transcription-text
LocalParticipant
RemoteParticipant
PubSub
SDKError
Errors