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.

You configure & dispatch from the SDK; the runtime is cloud-managed. With 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

SurfaceLives onWhat it does
meeting.startAgentMeeting (factory)Config-driven (or custom-agentId) dispatch: spawn an agent from the client. Pipeline mode derived from the config.
AgentParticipant β€” say/reply/interrupt + update/stopAgent participantSteer and reconfigure the running agent. Speak verbatim, ask the LLM to reply, cut it off, live-reconfigure, or stop it.
participant.kindEvery participant (Local + Remote)Discriminator: 'human' \| 'agent'. Server-set from how the participant connected.
participant.agentState + agent-state-changedEvery participant (null when not an agent)Conversational phase: idle / listening / thinking / speaking / failed.
agent-text eventRoomStreams transcripts from agents β€” both transcribed user speech and agent-generated text.
agent-metric eventAgent participantLazy per-metric stream mirroring the runtime's exact fields (llm_ttft, ttfb, latencies, tokens, …), tagged by component.
RPC β€” registerRpc / callRpcLocal + Remote participantsDirected 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.

meeting.startAgent(input: AgentConfig | AgentDispatch): Promise<AgentParticipant>; // Only creation is on meeting (the factory). update / stop live on the returned // AgentParticipant: agent.update(…), agent.stop(…) β€” see Β§Runtime control.

AgentConfig interface

interface AgentConfig { name?: string; // display name in the room; default 'Agent' instructions: string; // system prompt (mirrors Python Agent instructions) enterMessage?: string | true; // opening turn: string β†’ say (verbatim), true β†’ reply (LLM opens) exitMessage?: string | true; // closing turn before teardown: string β†’ say, true β†’ reply // ── pipeline components β€” all optional; mode is DERIVED (see below) ── stt?: { provider: string; model?: string; language?: string }; llm?: { provider: string; model?: string; temperature?: number; voice?: string }; // text OR realtime β€” runtime resolves tts?: { provider: string; model?: string; voice?: string }; vad?: { provider: string; threshold?: number }; turnDetector?: { provider: string; threshold?: number }; avatar?: { provider: string; avatarId?: string }; // visual avatar (Simli/Anam); avatarId = Simli faceId / Anam avatar_id interruptConfig?: { // barge-in tuning (mirrors InterruptConfig); always-on, no toggle mode?: 'vad_only' | 'stt_only' | 'hybrid'; // default 'hybrid' minDuration?: number; // s; default 0.5 minWords?: number; // default 2 minConfidence?: number; // 0..1; default 0.0 falseInterruptPauseDuration?: number; // s; default 2.0 resumeOnFalseInterrupt?: boolean; // default false fadeDuration?: number; // s; default 0.4 }; eouConfig?: { // end-of-utterance timing (mirrors EOUConfig) mode?: 'adaptive' | 'default'; // default 'default' minMaxSpeechWaitTimeout?: [number, number]; // s; default [0.5, 0.8] }; tools?: AgentTool[]; // function-tool schemas (executor = registered RPC handler) metadata?: Record<string, unknown>; // opaque, forwarded to the runtime }

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:

CapabilityConfig-driven (AgentConfig)Custom dispatch ({ agentId })
Spawn / stopβœ…βœ… (wraps POST /v2/agent/dispatch)
Pipeline / providersdeclared inlineowned 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
Toolsschema in tools[]agent-side @function_tool
agent.modederived from configreported if known, else undefined
say / reply / interruptβœ… guaranteedbest-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).

interface AgentDispatch { agentId: string; // custom agent deployed to Agent Cloud, or a registered self-hosted worker versionId?: string; // Agent Cloud version; latest deployed if omitted metadata?: Record<string, unknown>; // forwarded to the agent (β†’ dispatch metadata.variables) }
Example β€” dispatch a pre-deployed custom agent
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.

ConfigDerived mode
text llm + stt + ttscascade
realtime llm, no stt/ttsrealtime (full speech-to-speech)
realtime llm + ttshybrid_tts β€” realtime brain + your voice
realtime llm + stthybrid_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 / turn-detector scope. 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.

providerPathKeyBilled
'videosdk'inference gatewaynone β€” uses auth tokenorg's VideoSDK balance
'openai', 'deepgram', 'elevenlabs', …BYOKdashboard-stored, referenced by namethe provider directly
Mixing is allowed. A gpt-4o brain (BYOK) with a 'videosdk' gateway voice is just two independent resolutions. A named provider with no stored key β†’ MISSING_PROVIDER_CREDENTIAL.
Avatar = the agent's own video. A visual avatar (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.

interface AgentTool { name: string; // links to a registered registerRpc handler description?: string; // the LLM uses it to decide when to call parameters?: object; // JSON Schema; omit for no-arg tools executor?: string; // participantId that runs it; default = the dispatcher }
Example β€” cascade agent, BYOK, with a tool
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'
Example β€” realtime & hybrid (gateway, no keys)
// 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.

CallBehavior
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.
Permission. 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'.

interface AgentParticipant extends RemoteParticipant { readonly kind: 'agent'; readonly agentState: AgentState; readonly mode: 'cascade' | 'realtime' | 'hybrid_stt' | 'hybrid_tts'; // runtime control say(message: string, opts?: { interruptible?: boolean; addToContext?: boolean }): Promise<void>; // resolves when playback finishes reply(instructions: string, opts?: { interruptible?: boolean }): Promise<void>; // resolves when playback finishes interrupt(): Promise<void>; // lifecycle (only startAgent is on meeting β€” the factory) update(partial: Partial<AgentConfig>): Promise<void>; // live reconfigure; config-driven only stop(opts?: { force?: boolean }): Promise<void>; // graceful (speaks exitMessage); force skips it }
MethodWhat 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.
Example β€” speak, then act
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();
Python parity β€” 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.)
Example β€” the three wait_for_playback modes, via the promise
await 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)
Subtype, not a parallel class. 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.

readonly kind: 'human' | 'agent'; // default 'human'
ValueWhat 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.
Why not a token claim. Agents connect through a dedicated path that signals "I am an agent" server-side β€” the customer doesn't have to think about agent identity when minting tokens. The backend keeps minting normal tokens; the agent path does the rest. See AGENT_DECISIONS_V1.md Decision 1.
Extensible. When SIP / recorder / ingress participants land, the union extends ('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.

Example β€” branch on kind inside the join handler
room.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.

readonly agentState: 'idle' | 'listening' | 'thinking' | 'speaking' | 'failed' | null; p.on('agent-state-changed', ({ state, prev, error?: SDKError }) => { … });
StateMeaning
idleAgent is in the room but not actively in conversation. UI: "Ready."
listeningAgent is receiving / processing user audio. UI: mic indicator on agent's tile.
thinkingLLM is generating a response. UI: spinner / thinking indicator.
speakingAgent is producing audio response. UI: speaking indicator.
failedTerminal β€” agent encountered an error. error is set on the event payload; p.agentFailure persists on the participant.
Two access paths. Use the property for one-shot reads (initial render: "what's the agent doing right now?"). Use the event for transitions (animate the indicator as state changes). The event fires once per transition; the property always reflects the latest.

Failure payload

When agentState === 'failed', the transition event carries an SDKError in error. Same shape as every other failure in v1:

interface SDKError { code: string; // stable identifier β€” switch on this message: string; // human-readable; may evolve between versions kind: ErrorKind; // 'server' for agent failures (initially) retriable?: boolean; }
Initial codeWhenRetriable
AGENT_INTERNAL_ERRORAgent runtime crash / unhandled exception (generic β€” message carries the detail)true
AGENT_TIMEOUTLLM / TTS / STT call exceeded its budget; the agent gave uptrue
Example β€” render the agent's state in the UI
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.

room.on('agent-text', ({ participantId, text, isFinal, timestamp }) => { … });
FieldTypeDescription
participantIdstringWho the text represents. User-speech transcription: the user's id. Agent-generated text: the agent's id.
textstringThe transcribed / generated text content.
isFinalbooleanfalse for interim / streaming chunks. true for the committed final segment.
timestampDateWhen the text was produced.

Text flows on agent-text

ScenarioparticipantIdtext
User says "what's the weather?"; agent's STT transcribes ituser'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 tokensagent's ideach chunk fires isFinal: false; completion fires isFinal: true
Why a separate event from 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.
Example β€” render a chat-style transcript
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

me.registerRpc( method: string, // ≀ 64 chars handler: (req: RpcRequest) => Promise<unknown> ): void; me.unregisterRpc(method: string): void;

Call on the remote participant

p.callRpc( method: string, payload?: unknown, // JSON-serializable; ≀ 32 KiB encoded opts?: { timeout?: number; signal?: AbortSignal } ): Promise<unknown>;

RpcRequest interface

interface RpcRequest { from: string; // caller's participantId method: string; payload: unknown; signal: AbortSignal; // fires if caller cancels or times out β€” handler should respect it }
LimitValue
Method name≀ 64 characters
Payload (request + response, JSON-encoded)≀ 32 KiB
Timeoutdefault 10 s, max 60 s
Re-register same methodrejects with RPC_METHOD_ALREADY_REGISTERED
BroadcastNot supported β€” iterate per participant with Promise.allSettled
Where placement comes from. RPC lives on participants (not 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.
Grant requirement. Both caller and callee need 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 pathTool schema livesExecutor
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)

  1. Client β€” registers the executor: me.registerRpc('lookup_weather', async (req) => { … })
  2. Client β€” declares the schema: startAgent({ tools: [{ name: 'lookup_weather', description, parameters }] })
  3. LLM β€” decides to invoke a tool and emits a tool-call: { tool: 'lookup_weather', args: { city: 'SF' } }.
  4. Runtime β€” translates it into callRpc('lookup_weather', args) against the executor participant (the dispatcher by default; overridable per-tool via tools[].executor).
  5. Client handler β€” runs, returns the result.
  6. Runtime β€” feeds the result back to the LLM as a tool-result; the LLM continues.
The schema is data, and the 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 needHow RPC provides it
Named dispatchRPC method name ('lookup_weather' etc.)
Structured argumentspayload: unknown β€” JSON-serializable (matches OpenAI / Anthropic function-call argument shapes natively)
Return value to LLMPromise resolution; agent runtime forwards as tool-result
Errors visible to LLMRPC_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

FeatureWhy not
Dedicated me.registerTool({ name, schema, handler }) APIWould 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 SDKCustomers 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 gatingUse 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.
Example β€” registering a small toolkit (full pattern in Example 1 below)
// 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.

agent.on('agent-metric', (m: AgentMetric) => { … });

AgentMetric interface

interface AgentMetric { component: 'stt' | 'eou' | 'llm' | 'tts' | 'realtime' | 'turn'; // mirrors the runtime's component tag name: string; // EXACT runtime field name (snake_case, verbatim) value: number; unit: 'ms' | 'tokens' | 'count' | 'ratio' | string; timestamp: Date; turn?: string; // turn ID β€” correlates metrics from the same conversation turn metadata?: Record<string, unknown>; // free-form per-metric extras (model name, provider, etc.) }

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.

componentname (verbatim)UnitMeaning
sttstt_latencymsSTT recognition time for the turn
sttstt_confidenceratio0..1 confidence on the user's transcribed speech
eoueou_latencymsEnd-of-utterance detection time
eoueou_probabilityratioTurn-detection confidence
eoueou_wait_msmsTime waited for additional speech before committing
llmllm_ttftmsTime-to-first-token β€” LLM first output after user stops speaking
llmllm_latencymsLLM total time (first token β†’ done)
llmprompt_tokenstokensPrompt tokens consumed this turn
llmcompletion_tokenstokensCompletion tokens emitted this turn
llmtotal_tokenstokensPrompt + completion
llmtokens_per_secondtokens/sLLM generation throughput
ttsttfbmsTTS time-to-first-byte after LLM tokens start
ttstts_latencymsTTS synthesis time for the turn
ttstts_characterscountCharacters synthesized
realtimerealtime_ttfbmsRealtime model time-to-first-byte
realtimerealtime_input_tokenstokensRealtime input tokens
realtimerealtime_output_tokenstokensRealtime output tokens
realtimerealtime_total_tokenstokensRealtime input + output
turne2e_latencymsEnd-to-end response latency (stt + eou + llm_ttft + tts, or realtime_ttfb)
Mode dependency. Which metrics fire depends on the pipeline mode: cascade emits the 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."
Lazy delivery. The SDK refcount-gates upstream subscription β€” if your app doesn't register a handler, the server doesn't send metric events. Apps that don't care about metrics pay zero socket / parsing cost, matching the lazy-event pattern in MEETING_DECISIONS_V1.md Decision 7.
Custom names are first-class. The vocabulary above is what the runtime emits; agents may emit any additional name β€” the SDK doesn't validate. For agent-specific metrics, prefix with the agent's namespace (e.g. 'mybot_vector_search_ms') so they don't collide with runtime field names.
Example β€” render a "turn latency" badge in the UI
// 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);
  }
});
Example β€” usage / cost dashboard
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',
    });
  }
});
Example β€” surface slow turns to debug UI (dev mode)
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.

Example β€” client exposes location and calendar as tools
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).

Example β€” confirm before acting
// 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.

Example β€” parallel query, settled results
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.

Example β€” render the agent's response as it streams
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.

Example β€” route per-agent state
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')

CodeKindRetriableWhen
AGENT_INTERNAL_ERRORServertrueAgent runtime crash / unhandled exception (generic)
AGENT_TIMEOUTServertrueLLM / TTS / STT call exceeded its budget

Dispatch failures (rejected by meeting.startAgent)

CodeKindRetriableWhen
INVALID_AGENT_CONFIGConfigfalseNo llm; cascade missing stt/tts; or agentId mixed with pipeline fields
UNKNOWN_MODELConfigfalseprovider+model doesn't resolve to a runtime plugin
MISSING_PROVIDER_CREDENTIALConfigfalseNamed (BYOK) provider has no dashboard key
INVALID_PERMISSIONSAuthfalseToken lacks the allow_agent claim
AGENT_DISPATCH_FAILEDServertrueCloud couldn't allocate a runtime

RPC failures (delivered as Promise rejection of p.callRpc)

CodeKindRetriableWhen
RPC_METHOD_NOT_FOUNDServerfalseRecipient didn't register this method (or unregistered it)
RPC_TIMEOUTServertrueNo response within timeout ms
RPC_RECIPIENT_NOT_FOUNDServerfalseTarget participant left between call and ack
RPC_PAYLOAD_TOO_LARGEServerfalseRequest or response exceeds 32 KiB
RPC_HANDLER_ERRORServerfalseRecipient's handler threw β€” error message forwarded in details
RPC_METHOD_ALREADY_REGISTEREDServerfalseregisterRpc called twice for the same name without unregisterRpc
INVALID_PERMISSIONSAuthfalseCaller 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 SDKv1 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 / EOUConfiginterruptConfig / eouConfig
avatar= (Simli/Anam)AgentConfig.avatar (agent's own track)
@function_tooltools[] schema + RPC executor
AgentState / transcripts / metricsagentState Β· agent-text Β· agent-metric (exact mirror)
say / reply / interruptAgentParticipant runtime control
on_enter / on_exitenterMessage / exitMessage (declarative)
Dispatch (config + pre-deployed agentId)meeting.startAgent (two forms)
videosdk-inference + BYOKprovider: 'videosdk' / dashboard BYOK

Not covered

Python SDK featurev1 statusWhy
Provider fallback (FallbackSTT/LLM/TTS)⏸ Deferrederror + latency failover; needs provider-array config
De-noise (denoise)⏸ Deferredpipeline component
Knowledge base / RAG (KnowledgeBase)⏸ Deferredretrieval grounding
Memory (mem0)⏸ Deferredcross-session memory
MCP (MCPServerHTTP/Stdio)⏸ Deferredserver-side tool servers
Vision / multimodality (ImageContent, frames)⏸ Deferredvideo frames β†’ LLM
Background / thinking audio⏸ Deferredplay_background_audio
Context window / history (get_context_history, seeding)⏸ Deferredread / seed conversation history
userState (user_state_changed)⏸ Deferredagent's view of the human
Multi-agent handoff (AgentHandoff)❌ Not in v1one agent per dispatch
A2A (A2AMessage, AgentCard)❌ Not in v1inter-agent protocol
Conversational graph❌ Not in v1server-side structured flow
Preemptive response❌ Not in v1server latency optimization
Call transfer / warm transfer❌ Not in v1telephony
DTMF events (DTMFHandler)❌ Not in v1telephony keypad
Voicemail detection (VoiceMailDetector)❌ Not in v1telephony
TTS caching (TTSAudioCache)πŸ–₯ Server-sideruntime optimization, invisible to client
Pipeline hooks / observability / tracingπŸ–₯ Server-sideclient gets agent-metric
Worker / Agent Cloud / deploymentπŸ–₯ Server-sidedispatch surfaced via startAgent
Avatar Server (separate-participant topology)πŸ–₯ Server-sidev1 avatar = the agent's own track
Recording (RecordingOptions)↔ Elsewhereroom-level recording API
PubSub messaging↔ ElsewhereSDK 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).

FeatureStatusWhy / where
Binary RPC payloads (Uint8Array)⏸ DeferredString / object only in v1; base64-wrap if needed.
Broadcast RPC⏸ DeferredIterate per participant with Promise.allSettled in v1.
Streaming RPC responses⏸ DeferredThe canonical agent streaming pattern (LLM tokens) uses agent-text; RPC stays request β†’ one-response.
Parallel Agent class hierarchy❌ Not plannedAgents 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-managedConfig & 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 plannedBranch 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