Skip to main content
Live channels in Meerkat provide low-latency audio and text streaming plus model-gated still-image input through the live/* JSON-RPC methods. To use a live channel, create a session on a realtime-capable model (for example gpt-realtime-2) and call live/open to start the channel explicitly. Channel creation, status, refresh, input, interruption, truncation, and close are all caller-initiated through the live surface. This guide covers how to open live channels, send input, and observe channel state.

What this guide is for

Use this guide when you want to:
  • open a live audio/text channel on a session
  • send audio, text, or model-supported image input to a live channel
  • understand live channel lifecycle and capabilities
  • reason about live channels in the context of the normal session model
ModelCapabilities.realtime remains the capability bit that gates whether live/open succeeds. Image input is a separate per-channel capability: check LiveOpenResult.capabilities.image_in before sending an image. The --live-ws <addr> flag on rkat-rpc enables the WebSocket audio transport. For WebRTC, the binary must be compiled with the live-webrtc feature and started with --live-webrtc. At least one of these runtime transport flags is required for the live/* methods to be advertised.

Mental model

A session has exactly one conversation history. The session’s LLM client is the active delivery mechanism for that history; most models deliver via request/response (e.g. Anthropic claude-opus-4-8, OpenAI gpt-5.6-sol), a small class delivers via a persistent bidirectional socket (e.g. OpenAI gpt-realtime-2). The only thing a realtime-capable model changes is how the model is reached — the session still owns history, tools, context, and turn boundaries.
Key invariants:
  • One canonical history. The session is the source of conversational truth. Committed audio and text join the same history as non-live turns at turn boundaries. A provider-acknowledged image is materialized and persisted as canonical context before the channel emits its redacted user_content_committed receipt.
  • Capability gates channel open. ModelCapabilities.realtime is the signal that determines whether live/open succeeds. No channel is opened automatically.
  • Channel lifecycle is caller-initiated. Call live/open to start a channel, live/close to end it, and live/send_input / live/commit_input / live/interrupt / live/truncate to control flow.
  • Refresh without history replay. For ordinary OpenAI Realtime channels, live/refresh applies supported mutable settings such as visible tools. A canonical System-sequence change, model/provider swap, or canonical transcript or user-content-registry rewrite requires live/close + live/open. The adapter validates PCM at 24 kHz mono; refresh does not support arbitrary audio-format changes, and reopening does not make an unsupported format valid.

Realtime-capable models

ModelCapabilities.realtime: bool is set per model in the curated catalog (meerkat-models; the ModelCapabilities type lives in meerkat_core::model_profile). Capability is catalog data, not prefix inference:
  • OpenAI Realtime: gpt-realtime-2 — the model for the ordinary shipped Realtime adapter, with still-image input advertised as capabilities.image_in == true.
  • Public GPT Live: gpt-live-1 also advertises realtime == true, but requires the host-composed openai-live / execution-identity / WebRTC path described in GPT Live, not the Realtime WebSocket factory.
  • Private experimental GPT Live: gpt-live-1-codex is a separate experimental catalog row on the deprecated experimental-gpt-live path, not an ordinary Realtime model.
  • Gemini: reserved for future *-live* endpoints — no production models today.
  • Anthropic: no realtime-capable models today.
  • Self-hosted: realtime = false by default.
Use GET /models/catalog (REST) or models/catalog (RPC) to inspect which models advertise realtime == true in the running runtime. The transports in this guide belong to a native RPC host; a browser can be its media/signaling client. This is not the embedded @rkat/web WASM runtime. Switching the model, enabling --live-ws, or compiling openai-live alone does not install the public GPT Live host authority.

Opening a live channel

A live channel is opened by creating a session on a realtime-capable model and then calling live/open. These are separate lifecycle steps.

Create, then open

Create a session on a realtime-capable model, then call live/open:
live/open returns a LiveOpenResult containing a discriminated transport bootstrap, WireLiveChannelCapabilities, and WireLiveContinuityMode, plus advisories (left out when empty) naming what the open went ahead without, such as staged_prompt_refused. The advisory vocabulary may grow; an unknown advisory is informational, never an error. Omitted transport prefers WebSocket when its listener is configured and otherwise selects WebRTC when that is the only enabled transport. Request "transport": "websocket" or "transport": "webrtc" when the choice must be stable. WebSocket requires rkat-rpc --live-ws <addr>.
The direct WebSocket input path accepts JSON text chunks and negotiated raw PCM audio only, with a 2 MiB aggregate and per-frame ceiling. It does not accept inline images. Send every image through JSON-RPC live/send_input; the JSONL control plane accepts frames up to 64 MiB (excluding the newline), which accommodates the documented 20 MiB decoded-image ceiling plus base64 and envelope overhead.

WebRTC SDP exchange

WebRTC is a feature-gated runtime transport. A binary without the live-webrtc Cargo feature does not expose --live-webrtc or live/webrtc/answer. Start a capable binary with:
You can enable both transports at once:
Open the channel explicitly as WebRTC:
JSON-RPC
The result carries the channel-bound signaling material:
Result shape
The browser creates the offer. Include an audio track or the meerkat.live data channel before createOffer() so the SDP contains a media section, set the local description, and wait for ICE gathering to finish. Send that complete offer through JSON-RPC and install the returned answer:
Browser signaling sketch
Here opened is the live/open result and rpc is the application’s normal JSON-RPC request helper. The bootstrap token expires after 60 seconds, is single-use, and is bound to the returned channel. Replay, expiry, or a channel mismatch fails typed. JSON-RPC is the canonical signaling path; the optional http_url field in the transport union is only a convenience when a host provides one.

Bounding the initial seed

By default, live/open projects the full canonical history into the new realtime provider session. Long-lived sessions can request a smaller seed with the optional positive seed_max_chars parameter:
The core counts every serialized projected seed message, including active materialized System and SystemNotice rows, and selects a recent whole-turn suffix. It retains adjacent instruction/context prefixes with their turns rather than slicing an individual turn to fill the budget. Older instruction rows may therefore be omitted along with older dialogue; a System larger than seed_max_chars is not exempt from the window. An existing compaction summary may be retained as the head before the suffix when it fits. If selection omits any history, LiveOpenResult.continuity reports mode: "degraded". Omitting seed_max_chars preserves the full-seed behavior. The value must be positive; the server rejects zero. The full canonical System sequence is retained separately as a drift witness, not as a guarantee that every instruction is replayed to the provider. Canonical image identity, tombstone, and aggregate-accounting sidecars remain complete outside the seed-message window. Provider replay uses the selected messages; there is no separate full-transcript instruction replay sidecar.

Configuration defaults

For ordinary session creation without a model hint, set agent.model in the applicable effective realm configuration:
This is the unhinted session-model default, not an override of explicit or binding-specific model choices. Credentials and an enabled live transport are still required, and channels still open only through live/open. See the Configuration guide.

Observing channel status

Use live/status to read the current state of a live channel:

Channel close events

Every committed live channel close publishes one {"type": "live_channel_closed", "session_id": ..., "channel_id": ..., "reason": ..., "reopen_recommended": ...} event (AgentEvent::LiveChannelClosed) on the owning session’s event stream, so observers never poll live/status to learn that a channel closed or why. reason is one of: reopen_recommended is true only for a media_fault whose session may still reopen with its retained context.

Live channel methods

The shared live/* family is registered when at least one live transport is enabled: the WebSocket listener (rkat-rpc --live-ws <addr>) or a binary built with live-webrtc and started with --live-webrtc. live/webrtc/answer is registered only for the second case. When no WebSocket listener is configured, live/open defaults to WebRTC; with neither runtime flag, the live methods are not registered.

Live observations and durable history

The media transport and the session event log answer different questions. WebSocket or WebRTC carries low-latency channel input, output, and observations; committed turn content still lands in the canonical session transcript. Both transports forward each observation as it arrives, so a transcript frame can reach the client before its row commits (see Transcript rows while the member’s turn runs); the user_content_committed receipt for an image is still sent only once the image is persisted. The associated session-level session/subscribe event stream is a bounded live subscription. A lagging consumer receives a typed stream_truncated event with reason.kind: "stream_lagged" and reason.dropped, then continues from the retained tail. This reports a gap in that subscriber’s view, not a truncated transcript or durable event log. A response the provider ends without completing it arrives as a turn_failed observation. Route on failure.status (failed, incomplete or in_progress; a status without a wire name arrives as {"unknown": {"debug": ...}}); the provider’s reason, code and message are diagnostics only. It is not terminal: the channel stays open and the next turn proceeds. Nothing of the failed response is committed to the transcript, a WebRTC peer stops playing the audio still queued for it, and Meerkat does not retry it; the client decides whether to ask again. Its usage, when present, is charged to the session; when it is absent, the session event stream carries turn_usage_accounting_unmeasured instead. Built-in realm-backed persistent sessions can feed a separate durable event audit projector through an unbounded queue independent of the UI ring. The audit projection is asynchronous derived state, so when event evidence matters, resume from the last available durable cursor with events/list_since and reconcile against current session or channel state. Media frames and event projection are not session commit authority; the committed transcript and The RuntimeStore/backend carrier owns restart authority. SessionStore rows are component/content state, not a second currentness owner.

Sending image input

Image input is turn context, not a response trigger by itself. Check the channel’s image_in capability, submit the image with a caller-stable idempotency_key, wait for its durable receipt, then follow it with dependent text or audio. On an explicitly committed channel, live/commit_input can request a response from image-only context after that receipt arrives.
The key is session-scoped and must be non-empty, at most 128 UTF-8 bytes, free of control characters, and have no leading or trailing whitespace. Keep it stable until the durable receipt arrives. Retrying the same key with the same canonical MIME type and image bytes does not resend the image to the provider; it returns the already-committed identity through another receipt. Reusing the key for different content fails closed with image_input_idempotency_conflict. The data value contains the encoded image bytes as standard base64; do not include a data: URL prefix. OpenAI Realtime currently admits PNG and JPEG, verifies that the byte signature agrees with the declared MIME type, and enforces a 20 MiB decoded-image safety ceiling before provider send. Invalid keys, malformed base64, unsupported MIME types, content mismatches, and oversized images use the typed rejection reasons image_input_idempotency_key_invalid, image_input_invalid_base64, image_input_unsupported_mime, image_input_content_mismatch, and image_input_too_large. Before provider send, Meerkat also checks the canonical session’s cumulative decoded image history. A new image that would take that history above 40 MiB is rejected as image_input_history_budget_exceeded; it is not sent or persisted. A binding without image support uses image_input_not_implemented. Do not place an image behind uncommitted text or audio. Commit that input first, then submit the image as the first content in the fresh sequence; otherwise the adapter rejects it with image_input_requires_commit. This keeps the durable predecessor identity unambiguous.

Acceptance, rejection, and durability

live/send_input has deliberately layered outcomes: The same rejection reason can be immediate or asynchronous depending on which layer detects it. For example, malformed base64 or an invalid key is rejected before queue acceptance, while a content conflict discovered against durable session identity is reported after the command drains. A terminal live error or a missing receipt is never evidence of persistence. For a WebRTC or direct-WebSocket channel, send the image through JSON-RPC live/send_input, not the WebRTC data channel or direct WebSocket. The data channel’s fixed message ceiling is suitable for control/audio coordination but not full images. An image envelope delivered whole and decoded within the effective 65,535-byte ceiling receives the scoped image_input_transport_unsupported rejection, and that data channel remains open. A larger envelope may be rejected by the browser or SCTP transport before Meerkat can classify it, so it cannot receive a server-side typed rejection. Route every image through JSON-RPC; the data channel carries the receipt described below. If the adapter’s bounded image queue or provider-ack window is full, image_input_backpressured reports the byte ceiling without retaining another caller payload. Retry the same key after earlier image receipts arrive. The public transport does not echo image bytes back. After Meerkat has applied the image to canonical session history, it emits a redacted user_content_committed observation containing the item identity, content index, media type, and caller idempotency_key. WebRTC callers must wait for that receipt before sending RTP audio that depends on the image. Reopening the same session hydrates blob-backed user images and replays the typed images to the provider. Canonical live image history has a 40 MiB aggregate decoded-image ceiling (two maximum-size images); every history occurrence counts, including repeated references to the same blob. The live input gate enforces that same ceiling before accepting each new image, so a successfully committed live image cannot make an otherwise valid session unreopenable later. Existing legacy or out-of-band history above the ceiling, a missing blob, or bytes that do not match the durable content-addressed identity still fail live/open instead of trimming or silently changing visual context. Reduce canonical history explicitly or start a fresh session; reconnect never substitutes placeholders for accepted images.

End-to-end example

This example uses explicit_commit so the client owns audio turn boundaries. The ordinary default is provider_managed; do not call live_commit_input for that provider-managed VAD flow.

Live channels and mobs

Each mob member has its own session, so live channels are per-member by construction. To make a member live-capable, set its profile’s model to a realtime-capable model (for example in the MobDefinition TOML):
Mob member construction requires tools.comms = true independently of whether the profile’s model is live-capable. Open a member’s live channel through its mob after the member is spawned: mob/member_live_open over JSON-RPC, rkat mob live open, Python Mob.member_live_open or TypeScript Mob.memberLiveOpen. The mob checks the member first (not retiring, in cleanup or revoke, and restored), then the member’s own live host serves the requested transport or refuses it typed. A WebSocket bootstrap names the owning host’s advertised URL. With transport: "webrtc" the result is a WebRTC bootstrap: post the browser’s offer with live/webrtc/answer on the same JSON-RPC server, as for a session channel. A host that serves no WebRTC refuses it with LiveTransportUnsupported before it connects a provider; a member host started by rkat mob host serves WebSocket only, so a placed member’s channel is WebSocket. The session-scoped live/open refuses a mob member’s session (data kind mob_member_session_addressed_by_id), and controller session/* methods do not proxy a placed member’s host-local session in multi-host v1.

GPT Live (public OpenAI Live API)

gpt-live-1 is OpenAI’s continuous voice model on the public Live API. It is a different protocol from Realtime: audio runs continuously with provider-owned speech scheduling, and the voice model does not run tools. Instead the session is started with a client delegation: when the voice model decides a request needs tools, files, or reasoning, Meerkat runs the work through the channel’s ordinary durable executor and returns the result as commentary that the voice model speaks from. The canonical conversation persists through Meerkat’s normal transcript authority. The path is compiled with the openai-live feature (meerkat, meerkat-rpc, meerkat-mob, meerkat-mob-mcp; meerkat-openai/live). The host composes ExperimentalGptLiveOpenAuthority::new_public(PublicGptLiveOpenAuthorityConfig) with an OpenAI API-key binding in the realm, the model gpt-live-1, a voice, and optional trusted session instructions. No operator, Gate0, or realm admission applies: the catalog row and the configured binding are the admission. The gpt-live model family never selects the Realtime WebSocket factory. Known released public Live voice choices include alloy, ash, ballad, beacon, bossa, cedar, cinder, coral, delta, echo, gleam, marin, meridian, quartz, ripple, sage, shimmer, stone, tempo, verse, vesper, willow. This is not a closed set enforced by Meerkat: the broker warns for other nonempty names and forwards them to the provider. Acceptance still depends on the service and account; the warning does not prove that a custom voice is available. For HTTP 403 “Voice session access denied”, check both the selected voice and the organization’s Live access rather than ruling out an entitlement failure. Consumers open the channel through the existing execution-identity seam:
Then post the browser’s SDP offer with live/webrtc/answer and apply the answer in the peer. The browser must not send session.start; the server already started the session. Capability atoms are live.execution_identity.v1 and live.execution.client_context.v1, exactly as for the deprecated experimental profile. A mob member’s voice channel opens through its mob with the same selector, after the mob’s member checks:
Post the offer with live/webrtc/answer on the same connection, as above. The server’s execution-identity authority serves it exactly as it serves live/open, and mob/member_live_close closes the channel through that authority. An execution-identity authority’s refusal (access denied, an identity it does not admit, a member it finds ineligible) answers -32026 (host unavailable) with the authority’s reason, where live/open answers -32020 or INVALID_PARAMS. The member must be local to the JSON-RPC server: a placed member refuses execution_identity with Unsupported in 0.9.5, before anything reaches its host. Public Live has no authoritative turn identifiers, completed-turn events, or delegation task text. Meerkat synthesizes turns from transcript role alternation and joins each delegation to the open or most recent user turn, frozen to the transcript prefix observed before the delegation offset; the resulting delegated transcript is a Meerkat-derived fact. Transcript boundaries never prove playback completed. Assistant output may begin on a fresh channel from startup context or later canonical updates, without microphone input. Generated runtime authority keeps an existing foreground correlation when present; otherwise it admits a fresh assistant-only interaction with ProviderInitiated attribution. That attribution does not claim which context append caused the speech or prove provider consumption. It never creates a user transcript row. Later user input cannot rewrite an already admitted assistant output’s interaction or playback handle. Missing transcript events do not establish silence or completion. In particular, Meerkat does not finish assistant output after a quiet interval or after a delegation-result acknowledgement: a delayed readout retains its output identity. Applications must keep transcript grouping, playback evidence, and backend task completion separate; see OpenAI’s session guidance. While the user’s latest utterance is unanswered, Meerkat holds a delegation’s result (and its narration) behind it, so the voice model answers the user first; the model’s next output, the utterance’s own delegation, or 1.6 s of the user’s silence releases it. Each delegation carries a trusted in-progress instruction until its result arrives, so a result held back that way is finished work the model has not been told about. Meerkat therefore sends a notice bound to the delegation as soon as it holds a result, always before the result itself can be released: the work has finished and its result has not arrived, so the model may say it has just finished but must not state, guess or imply the outcome before the result reaches it. The notice carries no result content, and the result itself is still released only by those triggers. A finished delegation whose result Meerkat cannot deliver yet, for example while another delegation’s result holds the channel’s one result slot or while results wait for the provider to acknowledge a concurrent historical summary, gets the same notice. While the model is speaking, that notice waits until the response ends or the user takes the floor, and it is dropped if the result arrives first. On open and reopen, the selected canonical dialogue is supplied in native session.input at provider session creation. User text and visible assistant text retain their order and roles; executor System rows, SystemNotice rows, tool calls, and raw tool results are not voice-session instructions or dialogue. History is not injected as a speech-prompting session.commentary.append. The provider allows up to 128 startup messages and 8,192 combined tokens. Meerkat does not silently trim the selected seed to fit: select a bounded history window at the shared open-projection seam, or resolve an oversize-seed rejection before retrying. The canonical session history itself is unchanged.

Generated context summaries for an existing agent

A dedicated host can opt in to a generated factual summary, rather than a replayed history window. This is a Rust host composition seam, not a caller prompt field and not transcript compaction:
Implement LiveContextSummarizer::summarize(LiveContextSummarySnapshot<'_>) as an asynchronous, tool-free model call returning Result<String, LiveContextSummaryError>. The snapshot exposes session_id(), messages(), llm_identity(), canonical_message_cursor(), and max_output_bytes(). messages() contains the entire current model-boundary context window, including context and tool-result rows and canonical voice transcript blocks marked TranscriptSource::SpokenUnmeasured. Media remains typed references rather than newly hydrated images. Observed speech is not a new user request or proof of playback. Treat all source text as data; produce facts, decisions, ongoing work, and uncertainties, not executable instructions or assurances that the user heard unmeasured speech. llm_identity() is the existing background text identity before the voice override. A host can build a separate client using AgentFactory::build_llm_client_for_identity(&config, snapshot.llm_identity()). This resolves the configured binding through the factory/auth owner without changing the agent’s model, running its tools, or adding a transcript turn. An independently configured summary model must likewise resolve through that factory seam. Use the ordinary open_with_execution_identity method with seed_window: None. The host policy also regenerates summaries for both canonical-context and delegation-result replacement opens. Combining a summary policy with a replay window is rejected. Input overflow, empty or oversized output, timeout, source change during generation, and producer errors refuse opening without falling back to raw history. Limits count bytes, not characters or provider tokens; the provider’s independent startup token limit still applies. The shared owner seals the summary to the source session, exact body revision, rewrite generation, text identity, canonical cursor, and complete projected context digest. The deferred provider open and seed acknowledgement recheck the exact canonical source prefix. Observed transcript rows also advance the canonical revision/cursor and follow ordinary append-only catch-up. A conflicting body or rewrite cannot be disguised by an unchanged row count. The summary enters public Live as explicitly factual, unprivileged startup session.input, never session.instructions. Continuity is degraded, not a claim of lossless replay. ExperimentalGptLiveWebrtcTransport::bound_context_summary(channel, session) exposes read-only, channel-scoped summary provenance until unbinding.

Concurrent historical context

BeforeOpen remains the default. A dedicated public Live host can instead start native media without waiting for summary generation:
Configured binding and existing-member authorization still happen first. The shared owner captures one exact committed source and its text identity without acquiring the active background turn’s mutation guard or hydrating media. The original agent keeps its identity, model, tools, and full context. The callback runs concurrently; append-only growth does not restart it. The native session opens with no historical seed and no delivered canonical prefix. Generated authority reserves the captured prefix separately, then advances delivered coverage only after the exact quiet-context append is acknowledged. Historical facts enter the provider’s thinking lane, not speakable commentary or trusted session instructions. Mirrorable post-snapshot canonical text and peer rows follow that summary in order; newer live corrections are reasserted as factual context rather than fabricated user turns. Existing origin classification is unchanged: a generic inbox notice is not promoted into conversational human input by this policy. Read context_preparation() on the existing receipt-validated ExperimentalLiveChannelCustodyStatus. Its NotRequested, Preparing(stage), ProviderAcknowledged, and Failed(reason) values are independent of the channel’s media Pending/Active phase. A host may display “Listening, loading context” only after actual media activation. ProviderAcknowledged proves an exact provider receipt, not model consumption, spoken output, or playback. Summary failure remains visible while media can remain active; it never silently changes to ready or falls back to raw history. The provider owner bounds the total append and fragments it within the public 500-token limit. A complete acknowledgement requires every exact fragment receipt. A native rejection can follow partial delivery and is therefore ambiguous, not permission to replay the append blindly; actual channel-close interruption remains a separate outcome. Concurrent preparation is channel-owned. Exact close, identity change, or replacement cancels and fences that channel’s job. A late old result cannot populate a new channel. The same opt-in policy applies to replacement opens; their independent pending and activation receipts remain mandatory. For an availability indicator, call ExperimentalLiveOpenAuthorityProvider::probe_execution_readiness(session, execution_identity). Unlike catalog capability atoms, this checks the exact durable source and configured credential binding. It prepares and drops an unbound factory: no summary generation, text-model call, channel reservation, or provider session is started. Auth resolution can acquire or refresh its normal lease/credential, so this is not a zero-I/O status getter. Opening always revalidates; a previous successful probe is not an admission receipt.

Delegate to the selected existing member

The normal live delegation composer continues to create an isolated durable fork. A console host that intentionally wants the selected agent to perform voice-delegated work in its existing session can opt in at the mob feature boundary:
The coordinator resolves the source member from the channel’s authoritative session binding; the browser does not select a worker or supply an execution session. Voice work uses the member’s ordinary text model and generated input admission. Concurrent ordinary text remains independent work. Close and supersession cancel the exact voice operation, never retire the existing member or interrupt whichever unrelated user turn happens to be running. Operation ownership is retained with recovery evidence rather than reconstructed from the host’s current policy. Compose the returned coordinator into the same shared context-mirror/activation host as the default coordinator.

Transcript rows while the member’s turn runs

A live channel commits its transcript rows (the user’s finalized speech, the assistant’s spoken segments) one at a time, in their order on that channel, and each row waits for the session’s turn boundary: the same boundary a running turn of the member holds until that turn’s own rows commit. While such a turn runs (for example voice-delegated work in the existing member, above), later live rows wait behind it, so the persisted transcript can lag the conversation by up to that turn’s length. Once the turn’s boundary commits, the waiting rows commit in their original order. On a GPT Live channel they all commit then, before a member turn queued behind them (a typed message, a second delegation) starts: the transport holds the boundary across the rows it already has, for at most 64 rows or about a second at a time, so a queued turn waits at most that long for them. A WebSocket or WebRTC live channel queues its waiting rows in arrival order off the media path, so its voice, its other observations and the client’s input keep flowing while a member turn runs, and holds the boundary the same way across consecutive transcript rows. A turn completion or interruption row takes the boundary on its own, so while member turns queued on the session run back to back, the rows after it wait for the next of those turns. Such a channel forwards each observation as it arrives, so a frame can reach the client before its row commits; if the session refuses a row, the channel closes with an error frame (reason: "projection_error") naming the row’s kind (row), its provider item id (item_id) and how many later rows were not applied (unapplied_rows). Once 16 MiB of rows wait, the channel closes with reason: "projection_backlog_full"; every row it already queued still commits. These ordering guarantees hold per channel, not across channels of the same session, and the rows of a closed channel that are still committing can interleave with a reopened channel’s rows. A channel that closes while the member’s turn runs does not wait for the turn. The rows still waiting are handed to the close, which commits them once the turn’s boundary frees. If the turn ends with an error, they commit at once; if it keeps the boundary past the close settlement’s ten-minute hang guard, they still commit once it frees it, with no further deadline. Rows handed to a close this way are held in memory only, so they are lost if the process exits before they commit. If the turn’s boundary frees while the close is still settling, a later row can commit ahead of a row the close holds, and a channel reopened on the same session can commit its rows ahead of rows a closed channel still replays, so the transcript order can differ from the conversation; no row is lost. A close that fails before it commits leaves the rows it found deferred on the channel for its retry. On a WebSocket or WebRTC channel the rows its transport queued commit after the close in the same way, in order, also when the client disconnected, and are held in memory the same way. A row the session refuses for any other reason (for example an assistant output whose provider turn never reached the runtime) is not recorded. The session’s transcript then carries a RuntimeNotice system notice with category live_transcript_row_unrecorded whose payload names the channel, the row kind, its provider item id, its position among the rows the close held and the refusal class; it never carries the row’s content. A refused assistant output start is not reported on its own while later rows that settle the same provider item’s speech are still to be applied: a playback terminal (such as a segment’s release), a playback completion or truncation, or text the item stages (its final transcript or spoken transcript deltas) whose response then completes normally (not cancelled, not a tool call) with no barge-in interrupt discarding it first. The session applies those rows by its usual rules, or refuses one, and then that row is reported itself. Staged text is recorded only when its response completes normally, and a closed channel completes nothing on its own, so a start followed only by text whose response never completes that way is still reported. Text that arrives after its response’s completion is recorded at once, yet its start is still reported, so that notice over-reports. A refused completion’s own notice names no item or response, so the text it would have recorded cannot be attributed from the notice. Staged text is also recorded only after the item before it in the transcript, so text whose earlier item has not landed waits while its start is not reported; if that item never lands, the text is lost without a notice. The notice is recorded the same way as other transcript writes, so a session that refuses every write refuses it too, and only a warning remains.

Continuous media without fabricated playback completion

Direct public Live WebRTC provides no per-utterance rendered-completion event or mapping from media samples to Meerkat’s opaque output handles. Neither transcript grouping, response.done, audio-element ended, analyser silence, nor output-address publication establishes playback completion. For continuous console voice, opt in on the same public authority used by initial and replacement opens:
The default CallerConfirmedSnapshots mode remains available for hosts that can genuinely measure playback. In ProviderManagedUnmeasured, generated document authority settles each observed segment internally as unmeasured; it does not publish an actionable playback output handle or wait for a browser playback_complete call. Strict media-owner registration and activation still apply. Ordinary observation transport acknowledgement is never playback or hearing evidence. Observed assistant text is retained as canonical Transcript blocks with TranscriptSource::SpokenUnmeasured, through a distinct AssistantUnmeasuredSnapshotCommitted event. Committed-live provenance advances coverage without echo before segment advancement. Ordinary messages_for_model_boundary(), summaries, compaction, and reload therefore consume the same canonical history; there is no separate observation-position ledger or messages_for_live_context() overlay. The source marker must remain unmeasured when rendering or summarizing these rows. An observed snapshot has BlockAssistantMessage.stop_reason: None: it does not manufacture StopReason::EndTurn. Independently observed provider stop evidence remains optional throughout persistence and wire projection. Normal completed model responses still use Some(stop_reason); consumers must not substitute a completion for an absent value. Public admission diagnostics include the failing stage and typed cause (for example, missing_default_binding, lease_absent, or openai-live-openai-api-backend) without logging credentials, provider payloads, or connection URLs. These diagnostics do not change admission policy.

Barge-in playback hint

The gpt-live protocol has no client command that cancels or clears assistant audio the provider has already queued. When the user starts speaking over the assistant, that tail keeps playing until the provider stops it. Meerkat already measures both sides of the floor (the reflected input energy and the assistant output’s silence run), so it tells the client when to silence its own playback:
  • live/assistant_playback_hint { channel_id, hint: "duck" } is sent once when the user’s speech and audible assistant audio overlap, whichever side started: the user speaking over the assistant, or the assistant starting a response while the user keeps talking (at the user’s next voiced frame). A reply that starts after the user has stopped is never ducked.
  • live/assistant_playback_hint { channel_id, hint: "restore" } follows when the assistant output goes quiet for 1.6 s (the response ended) or when the user goes quiet for 1.6 s (including a false trigger: the user stops without taking the turn), whichever comes first. On the user’s quiet, the restore depends on what the duck covers. A burst the user interrupted, one the assistant was voicing when the user started speaking, is restored at its next pause of 600 ms (at once if it is already pausing that long), so the stale tail the provider voices before it yields is not replayed. That burst ends at its first 600 ms pause, even while the user is still talking: audio after that pause, like any response that starts into or after the user’s speech, is an answer and is restored at once, also in the middle of a sentence, so the answer is heard. An answer spoken entirely while the user is still talking stays ducked to its end, because the user held the floor for all of it. Meerkat tells the two apart by onset order on the provider audio clock (gpt-live-1 output frames carry no response id). A restore follows every duck, so a duck never leaves playback muted for the next turn.
The hint is purely additive. It is a notification, not a request: nothing waits for it, it changes no channel, transcript, playback or close semantics, and an undelivered hint is dropped. A client that ignores it behaves exactly as it did before the hint existed. Receiving the hint: rkat-rpc writes it on the JSON-RPC connection that owns the channel, beside the other live/* notifications. The TypeScript SDK delivers them typed through MeerkatClient.onLiveNotification(listener) and the Python SDK through MeerkatClient.on_live_notification(callback) (both return an unsubscribe function). Those clients drive rkat-rpc from a server process, so the page that plays the channel receives the hint over whatever connection already carries its live control traffic. The web SDK has no live transport. Hosts that compose their own surface on ExperimentalLivePublicObservationPublisher receive hints through its publish_playback_hint method; the default implementation drops them. Recommended client policy: route the remote assistant track through a gain node (with the media element muted so audio plays only through the graph), duck to silence on duck, and return to unity gain on restore, with a short ramp to avoid clicks. Place any meters or analysers after the gain node so they report what the user hears. The TypeScript SDK ships this policy:
The hint is advisory, and the policy above is the strict one: no assistant audio over the user. Its cost is a short user acknowledgement (“okay”, “mm-hmm”) loud enough to count as speech: playback stays ducked for the acknowledgement plus 1.6 s of the user’s silence, so roughly two seconds of a long readout go unheard. Faint backchannels under the speech threshold never duck. A client that prefers to keep backchannel-time audio over strict talk-over may attenuate partially instead of silencing, for example applyLiveAssistantPlaybackHint(gate, hint, now, { duckedGain: 0.2 }); the restore timing is the same either way. applyLiveAssistantPlaybackHint takes any object with a gain.setTargetAtTime method, so it stays independent of the DOM types. The constants LIVE_ASSISTANT_PLAYBACK_DUCKED_GAIN (0), LIVE_ASSISTANT_PLAYBACK_UNITY_GAIN (1) and LIVE_ASSISTANT_PLAYBACK_GAIN_TIME_CONSTANT_S (0.01) are exported for clients that implement the gate themselves.

Media health of the first output

A public Live WebRTC session can occasionally carry silent media for its whole life while the model speaks: transcripts arrive, but the remote audio track decodes as silence. The runtime catches this on each channel’s first assistant output, in ProviderManagedUnmeasured mode:
  1. When that output’s first segment commits with a non-empty transcript, the runtime sends live/media_health_requested { channel_id, output_id }. The output_id is only a report key (that output is already settled; it is not a playback handle).
  2. The client answers with live/media_health { channel_id, output_id, decoded_frames, audible_frames, max_rms }: raw counters from the channel’s media start to now (nothing was audible before the first output). audible_frames counts decoded frames whose RMS reached the client’s non-silent floor, and max_rms is linear amplitude (0.0 to 1.0). The client never sends a verdict.
  3. The runtime judges the counters. audible keeps the channel open. A media_fault (no audible frame and a peak below 0.002) means the runtime has already closed the channel through the ordinary close (retained summary custody included) before it answered. reopen_recommended is true for the session’s first media fault, so the client can reopen with the retained context. A second fault on the same session closes without the recommendation, so a broken media path never loops.
Observers that did not send the report (a UI, the owning agent, other SDK consumers) learn the close from the session event stream (see “Channel close events” below). live/status also reports {"status": "closed", "reason": "media_fault", "reopen_recommended": true}. A plain close still reports {"status": "closed"}. The request is made once per channel. A report for an output the runtime did not request, a repeated report, or a report for an inactive channel is refused. A client that never answers leaves the output unjudged: no fault and no reopen. For public Live, live/playback_complete is an explicit caller-confirmed snapshot cut: it commits the transcript prefix observed at that ordered checkpoint, without waiting for the next user turn or a close. It does not claim that the provider finished its turn or that a person heard the text. Later continuation remains in the same interaction and provider group but receives a fresh one-use playback output handle after the cut settles. A late group final cannot replay a committed prefix or extend an earlier playback report to cover later text. Callers may await playback completion and then close sequentially. Explicit live/truncate prefixes use the same ordered snapshot checkpoint and must match its exact prefix; they do not wait for a provider final. Omitting a prefix remains unmeasured. Every terminal kind, including unmeasured and legacy complete/prefix reports, records exact settlement facts before its target is retired. Internal retry after a lost projection receipt reuses those facts, never a new caller report, changed accounting, or an expanded prefix. Closing drains provider observations, canonical transcript projection, and the bound control consumer before channel terminality and resource retirement. Already-admitted output publications retain access to their lifecycle custody during drain; terminal close takes its exclusive lifecycle lease afterward. Failed projection or publication retains the exact observation and completed steps. Retrying close resumes those steps without re-admitting output or duplicating already committed transcript text. Exactly correlated pending-append errors during close become InterruptedByClose: completion failed, but partial provider consumption is unknown. They are neither acknowledgements nor proof of zero consumption; generated authority permits no retry or automatic replacement. They do not prevent draining the subsequent close confirmation. An independently reported playback completion can join a final transcript that arrives during close; closing itself never proves playback or hearing. Output without playback evidence remains explicitly unmeasured. Canonical context appends are claimed by generated authority. A concurrent drain of the same pending edge defers without reminting send authority; an already acknowledged edge is not resent. A provider turn that wins the boundary also produces a typed deferral, not a projection failure. Rows from the session’s other activity, such as a typed message and its reply, reach the voice model’s context only between provider turns: while the provider holds a turn open, including a user turn it has not finished, they wait in order and are appended once that turn ends, and they remain in the session transcript meanwhile. Local cursor/row bookkeeping is serialized only during projection and receipt realization, never while waiting for a provider acknowledgement. Enqueue acknowledges store-sealed canonical staging, not provider delivery. An owned, channel-scoped drain retains submission and error custody independently of the committing turn or any cancelled observer; exact close flushes that worker’s quiescence before committing terminality. A historical post-commit delivery/recovery failure remains observable and is reported during close, but cannot block cleanup once its worker stopped and physical retirement is proven. This does not bypass failed canonical transcript projection: that still blocks the physical drain. A streamed service turn that promotes its provisional tail still projects the exact promoted store boundary. Provider turn-finish callbacks wake that owned task without awaiting it, including empty or idempotent transcript completions. Generated close revocation defers new sends; a generated ambiguity recovery realizes its own old-channel close without trying to join the worker that is performing that recovery. Assistant rows attributed by generated authority to an existing voice worker are executor trace for generic mirroring: their result has the separate delegation-specific delivery path. Later ordinary background interactions remain eligible for generic context delivery. Ordinary runtime-backed turns, including RPC turn/start and the mob-owned executor used for asynchronous peer work, notify an owned context-projection task after the CoreExecutor acknowledges the committed boundary. That task reads the store-sealed canonical body after executor locks are released; it never reconstructs history from turn results or UI events. It rechecks the target channel after each read, so a delayed read cannot publish through an old channel after close/reopen. A failed post-commit source read or classification is retained for explicit drain observers and reported during close; a later commit notification retries the canonical scan. Close does not wait for a source read held behind unrelated background work. This derived context delivery is distinct from committing incoming live transcript evidence: a failure of the latter still blocks the physical drain. Mirroring classifies each committed row independently. User speech retains session-, channel-, and row-scoped domain provenance before canonical hashing, including when materialization is deferred behind another transcript item. Thus a live callback cannot swallow preceding ordinary text rows, and a text callback cannot echo speech already present on that channel. Generic transcript rewrites clear replacement-row provenance; another session, channel, or row cannot reuse it. Attributed realtime sidecar events and snapshots use schema 2; new readers verify both schemas 1 and 2, while older readers reject schema 2. This domain provenance is used with the store witness and generated channel binding, not as a standalone transport authorization token. Both channel-addressed RPC close and exact-receipt host close revoke generated append and recovery custody before physical retirement. A delayed committed source read therefore cannot turn an explicit close into a fresh replacement channel. Context and result recovery use separate sealed close paths that preserve their own replacement authority; they are not ordinary explicit closes. Both recovery activations require playback-owner readiness, retain the original pending receipt, and mint a fresh activation receipt in the generated answer commit. An activated replacement remains exactly closeable. Revoked or closed replacements cannot be published by a late registration callback. Provider ingress routes append receipts before the control consumer, so a control task committing its final user transcript cannot wait on its own receive loop. Duplicate high-volume transcript notifications stay on the adapter projection path rather than filling the control queue. Canonical delegation-final commits include live-provenance context projection before releasing their evidence to the worker. The MobSessionService method commit_live_delegation_final_transcript requires the same MeerkatMachine reference explicitly; decorators must forward it, not inherit a default. A failed projection, missing drain receipt, or unconfirmed physical retirement retains the exact channel custody and returns an error. Retry the same channel after the blocking work settles; a timeout does not cancel or repeat an in-flight provider close. A terminal provider read failure (including a dropped sideband socket without session.closed) is different: the shared owner drains the known observation prefix, retires the failed local transport, commits generated channel close, then projects the retained terminal fault before releasing registration. This can return Closed without a graceful remote close acknowledgement; it does not assert a successful model turn, final transcript, or playback. If fault projection fails after generated close, exact close must still be retried: the owner retains the reporting obligation and replays it without duplicating a completed publication. Do not skip exact close just because status already says Closed, and do not infer terminality from a generic transport error in a surface handler. Pending fault reporting pins its transport projection past the normal post-close cache TTL. Once cleanup completes, exact receipt replay relies on generated custody, not that cache. The generated-close/host-commit handoff continues in an owned task if the close observer is cancelled. The sideband owner declares ProviderWebrtcEofEvidence: EOF defaults to Unconfirmed, while ProviderConfirmed requires its explicit protocol receipt. An unconfirmed clean EOF is retained as a ConnectionLost fault, not a graceful provider close. The public OpenAI broker rejects both pre-close and mid-close WebSocket EOF lacking session.closed as transport failure. Known terminal drain evidence can retire a hung close request locally; an unfinished projection or control consumer still blocks successful cleanup. An ordinary session turn holding pending tools may return a busy close-settlement error; closing a live channel does not authorize cancellation or fabricate those tools’ results. Ambiguous-delivery replacement bootstraps retain their machine-issued pending_receipt. Register the replacement playback owner with that receipt before answering its offer, just as for an initial strict open. The shared ExperimentalLiveReplacementRequired value exposes open(), pending_receipt(), and canonical_seed_cursor(). Project it to a strict pending handle, register the new media owner, answer that replacement’s offer, publish answer-delivery custody, and obtain its active receipt from status. Do not issue a second fresh open or reuse the old channel’s activation receipt. Ordinary text updates do not switch the durable text model; a lost replacement notification can be recovered by pulling the same pending bootstrap until its answer binds or that exact channel closes. Explicit user close must call the shared exact-receipt close even when the known channel already reports Closed or Revoked. Recovery can have closed its old transport while a summary or credential preparation is still pending. close_experimental_live_pending_channel accepts the original pending receipt; close_experimental_live_active_channel accepts its activation receipt. Generated close authority cancels only that channel’s outstanding recovery candidates, refuses their late admission or activation, and retires any admitted candidate. It cannot cancel a later independent open on the same session. The internal recovery handoff close does not cancel its own replacement. Retained replacement bootstraps are mechanical publication custody: closing a never-activated replacement removes its entry, and delayed old close feedback cannot remove a newer entry. For the default BeforeOpen policy, cancellation invalidates generated recovery authority; it does not forcibly drop an arbitrary host summary or credential future. Until that bounded preparation finishes or times out, its existing projection-memory reservation may temporarily backpressure another open. Hosts should surface that refusal and retry within their own bound, not infer a new channel or bypass the shared reservation. A process admits live opens one at a time and queues none. An open keeps that admission until its provider has acknowledged its seed: for a provider-brokered WebRTC channel (the provider answers the client’s offer), through that offer and answer. A second open during that setup is refused before anything opens, as the typed RealtimeSessionOpenProjectionError::Backpressured (SessionBusy, -32002, on rkat-rpc live/open; MemberLiveError::Unavailable from a member live host). The refusal is retryable: retry the open with backoff. The private ChatGPT-brokered path (experimental-gpt-live, gpt-live-1-codex) is deprecated and will be removed after the public path has shipped for at least one release.

Limitations and known gaps

  • Ordinary/default composition uses OpenAI Realtime. Its provider integration is OpenAI’s Realtime API (gpt-realtime-2), not Azure OpenAI (azure_openai) or other providers. Public gpt-live-1 uses the separate host-composed openai-live / execution-identity / WebRTC path above; the private experimental path is deprecated.
  • One live channel per session. A session has at most one live channel at a time. For per-member live channels in mobs, open channels against individual member sessions.
  • Deferred sessions are model-gated. live/open may materialize a deferred session whose resolved model is realtime-capable; a deferred non-realtime session is rejected before channel creation. When the open materializes a deferred session, or opens one whose create-time prompt is still staged, the prompt reaches the realtime model as the session’s first user row: the run-start hooks admit it once, and a later text turn does not deliver it again. A hook that refuses it keeps it staged for the first text turn; the open goes ahead without it and reports staged_prompt_refused in the result’s advisories.
  • Live rows wait for a running member turn. Transcript rows commit in arrival order behind a running turn of the member, so the persisted transcript can lag the conversation by that turn’s length (on a WebSocket or WebRTC channel, by each member turn queued behind it while turns run back to back; that channel’s voice and events keep flowing meanwhile); rows handed to a channel close are held in memory until they commit and are lost if the process exits first, and a row released mid-close can commit ahead of an earlier held row (see Transcript rows while the member’s turn runs).
  • Ordinary Realtime refresh is not instruction/history replay. Supported mutable settings such as visible tools can refresh; canonical System-sequence changes, model/provider swaps, and canonical transcript or user-content-registry rewrites require live/close + live/open. The adapter remains fixed to PCM 24 kHz mono, including after reopen.
  • GPT Live may delegate a question asked over its own playback. Public gpt-live-1 sessions use client delegation for the whole session, and the provider’s delegation events carry no typed reason, so Meerkat cannot keep a single turn native. A question that starts while the assistant is still speaking, before the cue for a delegated result has been sent, can be routed to the executor even when it asks about the conversation itself. The user then hears the answer late, in the same voice, once the executor’s turn has finished and its result has been spoken after at least 1.6 s of output silence; occasionally the voice instead resumes its earlier readout and never answers, and the question has to be asked again.

See also