> ## Documentation Index
> Fetch the complete documentation index at: https://docs.platform.decart.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Realtime API

> Transform video streams in realtime on Android

The Realtime API transforms live camera video with minimal latency. Signaling is a Decart-owned WebSocket; media flows through a LiveKit room that the SDK joins for you. Perfect for camera effects, video restyling apps, AR experiences, and interactive live streaming.

## Quick Start

```kotlin theme={null}
import ai.decart.sdk.DecartClient
import ai.decart.sdk.DecartClientConfig
import ai.decart.sdk.RealtimeModels
import ai.decart.sdk.realtime.ConnectOptions
import ai.decart.sdk.realtime.FacingMode
import ai.decart.sdk.realtime.InitialPrompt
import ai.decart.sdk.realtime.MirrorMode

val client = DecartClient(context, DecartClientConfig(apiKey = "your-api-key"))
val realtime = client.realtime

// Connect and let the SDK open the camera. The remote (transformed) stream
// is delivered via onRemoteStream.
realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_RESTYLE_2,
        initialPrompt = InitialPrompt("a cyberpunk cityscape"),
        facing = FacingMode.FRONT,
        mirror = MirrorMode.AUTO,
        publishCamera = true,
        onRemoteStream = { stream ->
            // Render stream.videoTrack with a LiveKit renderer (see below).
        },
    ),
)

// Change the style mid-session — suspends until the server acks.
realtime.setPrompt("a sunny beach scene", enhance = true)

realtime.disconnect()
client.release()
```

## Client-Side Authentication

For production Android apps, use ephemeral keys instead of embedding your permanent API key in the APK. Ephemeral keys are short-lived tokens safe to include in client applications.

<Info>
  Learn more about [client tokens](/getting-started/client-tokens) and why they're important for security.
</Info>

### Fetching an Ephemeral Key

Your app should fetch an ephemeral key from your backend server before connecting:

```kotlin theme={null}
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject

suspend fun fetchEphemeralKey(): String = withContext(Dispatchers.IO) {
    val client = OkHttpClient()
    val request = Request.Builder()
        .url("https://your-backend.com/api/realtime-token")
        .post("".toRequestBody())
        // Add any auth headers your backend requires
        // .addHeader("Authorization", "Bearer $userToken")
        .build()

    val response = client.newCall(request).execute()
    if (!response.isSuccessful) {
        throw Exception("Failed to fetch token: ${response.code}")
    }

    val body = response.body?.string()
        ?: throw Exception("Empty response body")
    JSONObject(body).getString("apiKey")
}
```

### Connecting with an Ephemeral Key

```kotlin theme={null}
val ephemeralKey = fetchEphemeralKey()

val client = DecartClient(
    context = applicationContext,
    config = DecartClientConfig(apiKey = ephemeralKey)
)

// Connect as usual
client.realtime.connect(options)
```

<Warning>
  Never hardcode your permanent API key in Android apps. APKs can be decompiled, exposing embedded secrets. Always use ephemeral keys from your backend.
</Warning>

## Connecting

### Camera ownership

You have two options for the local stream:

* **SDK-owned camera (simplest).** Pass `publishCamera = true` to `ConnectOptions` and the SDK opens the camera, joins the LiveKit room, and publishes for you.
* **Caller-owned preview.** Call `realtime.createLocalVideoStream(model, facing, mirror)` ahead of time to drive a preview UI before connecting, then pass the resulting `RealtimeMediaStream` to `connect(...)`. Preview and publish share the same LiveKit `Room`. Dispose the stream when you're done.

```kotlin theme={null}
val model = RealtimeModels.LUCY_2_1

// Caller-owned preview — sized from the model's capture resolution.
val localStream = realtime.createLocalVideoStream(
    model = model,
    facing = FacingMode.FRONT,
    mirror = MirrorMode.AUTO,
)

// Later, when the user hits Connect:
realtime.connect(
    options = ConnectOptions(
        model = model,
        publishCamera = true,
        onRemoteStream = { stream -> /* render stream.videoTrack */ },
    ),
    localStream = localStream,
)
```

There's also a static `RealTimeClient.createLocalVideoStream(context, model, ...)` for building a preview before you've decided which API key to use.

<Warning>Camera capture requires a **real Android device**. The emulator does not support camera-driven realtime sessions.</Warning>

### Rendering with LiveKit

Realtime media tracks are LiveKit tracks. Use a LiveKit renderer such as `TextureViewRenderer` or `SurfaceViewRenderer` and initialize it with the Room's `EglBase` context:

```kotlin theme={null}
import io.livekit.android.renderer.SurfaceViewRenderer
import io.livekit.android.room.track.VideoTrack

// In your composable or view, given a RealtimeMediaStream `stream`:
val room = stream.room ?: return
val renderer = SurfaceViewRenderer(context)
renderer.init(room.lkObjects.eglBase.eglBaseContext, null)
stream.videoTrack?.addRenderer(renderer)
```

You can react to remote-stream changes (initial frame, auto-reconnect rebinds) via `realtime.remoteStreamUpdates`:

```kotlin theme={null}
lifecycleScope.launch {
    realtime.remoteStreamUpdates.collect { stream ->
        previousRenderer?.let { stream.videoTrack?.removeRenderer(it) }
        stream.videoTrack?.addRenderer(remoteRenderer)
    }
}
```

### Front-camera mirroring

`mirror` pre-flips the captured frames **before** they're published, so server-baked pixels (watermarks, overlays) remain readable. Render both local previews and remote streams as-is — do **not** also set renderer-level mirroring or you'll double-flip.

```kotlin theme={null}
realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        facing = FacingMode.FRONT,
        mirror = MirrorMode.AUTO, // default: front mirrored, back as-is
    )
)
```

`MirrorMode` values:

* `OFF` — never mirror.
* `ON` — always mirror.
* `AUTO` (default) — mirror only when `facing == FacingMode.FRONT`.

The same `mirror` argument is available on `createLocalVideoStream(...)`.

### Output resolution

Opt into 1080p output from supported models; otherwise the server defaults to 720p:

```kotlin theme={null}
import ai.decart.sdk.realtime.Resolution

realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        resolution = Resolution.P1080,
        onRemoteStream = { /* ... */ },
    )
)
```

### Reference images

Send a reference image at connect time via `initialImage` (base64-encoded):

```kotlin theme={null}
val characterBase64 = Base64.encodeToString(characterBytes, Base64.NO_WRAP)

realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        initialImage = characterBase64,
        initialPrompt = InitialPrompt(
            text = "Substitute the character in the video with the person in the reference image.",
            enhance = true,
        ),
        onRemoteStream = { /* ... */ },
    )
)
```

<Tip>
  Set `initialImage` and/or `initialPrompt` so the first frame is already transformed — otherwise viewers briefly see the raw camera feed.
</Tip>

### Tuning the LiveKit publisher

Override codec, bitrate, or framerate via `RealtimeConfiguration`:

```kotlin theme={null}
import ai.decart.sdk.realtime.RealtimeConfiguration

realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        realtimeConfiguration = RealtimeConfiguration(
            media = RealtimeConfiguration.MediaConfig(
                video = RealtimeConfiguration.VideoConfig(
                    preferredCodec = "H264", // default: "VP8"
                    maxBitrate = 2_500_000,
                    maxFramerate = 30,
                )
            )
        ),
    )
)
```

Defaults: codec VP8, max bitrate 2,000,000 bps, max framerate 30 fps, simulcast on.

### Realtime audio

The Android LiveKit publisher is **video-only** in 0.7. `publishMicrophone`, `includeMicrophone`, and `RealtimeMediaStream.audioTrack` are retained for source compatibility but are deprecated and ignored — SDK-created streams always expose `audioTrack = null`. Use the JavaScript or Swift realtime SDKs if you need audio.

## Managing Prompts

`setPrompt` is a `suspend` function that waits for the server ack and throws on ack failure, timeout, or websocket disconnect. Always call it from a coroutine.

```kotlin theme={null}
lifecycleScope.launch {
    try {
        realtime.setPrompt("Anime style", enhance = true)
    } catch (e: Exception) {
        showError("Prompt rejected: ${e.message}")
    }
}
```

**Parameters:**

* `prompt: String` (required) - Style description
* `enhance: Boolean` (optional) - Auto-enhance the prompt (default: `true`)
* `timeoutMs: Long` (optional) - Ack timeout in milliseconds (default: `15_000`)

If you'd rather fire-and-forget and observe the ack later, use `setPromptAsync(...)` which returns `Deferred<Unit>`:

```kotlin theme={null}
val ack = realtime.setPromptAsync("Cyberpunk city")
// ... later
try { ack.await() } catch (e: Exception) { /* ack failed */ }
```

<Note>Prompt enhancement uses Decart's AI to expand simple prompts for better results. Disable it if you want full control over the exact prompt.</Note>

## Reference Images

Send a reference image (and optionally a prompt) for image-guided models. Also a `suspend` function with a `Deferred`-returning sibling.

```kotlin theme={null}
lifecycleScope.launch {
    // Set image with prompt
    realtime.setImage(
        imageBase64 = base64EncodedImage,
        prompt = "Transform into this character",
        enhance = true,
    )

    // Clear image
    realtime.setImage(imageBase64 = null)
}

// Or, fire-and-forget with a wait handle:
val ack = realtime.setImageAsync(imageBase64 = base64EncodedImage, prompt = "...")
ack.await()
```

**Parameters:**

* `imageBase64: String?` (required) - Base64-encoded image, or `null` to clear
* `prompt: String?` (optional) - Text prompt to send with the image
* `enhance: Boolean?` (optional) - Whether to enhance the prompt
* `timeout: Long` (optional) - Ack timeout in milliseconds (default: `30_000`)

## Connection State

Monitor connection state changes using a Kotlin `Flow`:

```kotlin theme={null}
import ai.decart.sdk.ConnectionState

lifecycleScope.launch {
    realtime.connectionState.collect { state ->
        when (state) {
            ConnectionState.DISCONNECTED -> showReconnectButton()
            ConnectionState.CONNECTING -> showLoadingIndicator()
            ConnectionState.CONNECTED -> hideLoadingIndicator()
            ConnectionState.GENERATING -> showGeneratingUI()
            ConnectionState.RECONNECTING -> showReconnectingBanner()
        }
    }
}

val isConnected = realtime.isConnected()
```

**Connection States:**

* `DISCONNECTED` - Not connected (initial state, after `disconnect()`, or after reconnect failure)
* `CONNECTING` - Initial connection in progress
* `CONNECTED` - Connected and ready to send prompts
* `GENERATING` - Actively generating transformed video (sticky until disconnected)
* `RECONNECTING` - Connection lost unexpectedly; the SDK is automatically retrying

<Info>
  The SDK automatically reconnects when an unexpected disconnection occurs (e.g., network interruption). During auto-reconnect, the state transitions to `RECONNECTING` while the SDK retries. If all retries fail, the state moves to `DISCONNECTED` and an error is emitted. A new `RealtimeMediaStream` is delivered via `remoteStreamUpdates` after each successful rebind — re-attach your renderer.
</Info>

## Error Handling

Errors are emitted via the `errors` SharedFlow:

```kotlin theme={null}
import ai.decart.sdk.DecartError
import ai.decart.sdk.ErrorCodes

lifecycleScope.launch {
    realtime.errors.collect { error ->
        when (error.code) {
            ErrorCodes.INVALID_API_KEY ->
                showError("Invalid API key. Check your credentials.")
            ErrorCodes.WEBRTC_TIMEOUT_ERROR ->
                showError("Connection timed out. Check your network.")
            ErrorCodes.WEBRTC_ICE_ERROR ->
                showError("ICE negotiation failed.")
            ErrorCodes.WEBRTC_WEBSOCKET_ERROR ->
                showError("WebSocket connection error.")
            ErrorCodes.WEBRTC_SERVER_ERROR ->
                showError("Server error. Try again later.")
            ErrorCodes.WEBRTC_SIGNALING_ERROR ->
                showError("Signaling error.")
            else ->
                showError("Error: ${error.message}")
        }
    }
}
```

**Error Codes:**

* `INVALID_API_KEY` - API key is invalid or missing
* `WEBRTC_TIMEOUT_ERROR` - Connection timed out
* `WEBRTC_ICE_ERROR` - ICE negotiation failed
* `WEBRTC_WEBSOCKET_ERROR` - WebSocket connection error
* `WEBRTC_SERVER_ERROR` - Server-side error
* `WEBRTC_SIGNALING_ERROR` - Signaling protocol error

## Publisher Stats

The `stats` SharedFlow emits outbound (publisher) video metrics roughly once per second:

```kotlin theme={null}
lifecycleScope.launch {
    realtime.stats.collect { event ->
        println("Encoded ${event.deltaFrames} frames (${event.frameWidth}x${event.frameHeight})")
        println("Sent ${event.deltaBytes} bytes")
        event.qualityLimitationReason?.let { println("Quality limited by: $it") }
    }
}
```

`PublishStatsEvent` fields:

* `bytesSent`, `deltaBytes` - Cumulative and per-sample bytes sent
* `framesEncoded`, `deltaFrames` - Cumulative and per-sample frames encoded
* `frameWidth`, `frameHeight` - Current encoded frame size
* `encoderImplementation` - Codec implementation reported by libwebrtc
* `qualityLimitationReason` - `"cpu"`, `"bandwidth"`, etc. when the encoder is throttling

## Generation Ticks

Track session duration for billing and usage display:

```kotlin theme={null}
lifecycleScope.launch {
    realtime.generationTicks.collect { tick ->
        println("Generation running for ${tick.seconds} seconds")
        updateBillingUI(tick.seconds)
    }
}
```

## Diagnostics

Monitor detailed connection diagnostics for debugging:

```kotlin theme={null}
import ai.decart.sdk.realtime.DiagnosticEvent

lifecycleScope.launch {
    realtime.diagnostics.collect { event ->
        when (event) {
            is DiagnosticEvent.PhaseTiming ->
                println("${event.data.phase}: ${event.data.durationMs}ms")
            is DiagnosticEvent.FirstFrame ->
                println("First remote frame in ${event.data.timeSinceConnectMs}ms")
            is DiagnosticEvent.Reconnect ->
                println("Reconnect attempt ${event.data.attempt}/${event.data.maxAttempts}")
            is DiagnosticEvent.VideoStall ->
                println("Video stall: ${if (event.data.stalled) "detected" else "recovered"}")
            is DiagnosticEvent.PublishStats ->
                { /* see Publisher Stats above — same payload also delivered here */ }
            else -> { /* ICE, peer connection, signaling state changes */ }
        }
    }
}
```

## Connection Quality

Two layers report network health on a shared `GOOD | FAIR | POOR | CRITICAL` scale: a **preflight** check before connecting, and an **in-session** signal while connected.

### Preflight

A fast, network-only reachability check. No session, no cost:

```kotlin theme={null}
import ai.decart.sdk.realtime.ConnectionQuality

val report = realtime.checkConnectivity() // suspend
// report.metrics: transport (UDP | RELAY | FAILED), rttMs
if (report.quality == ConnectionQuality.CRITICAL) showFallbackUi(report.reasons)
```

### In-session quality

While connected, the SDK derives a smoothed verdict from live connection stats (latency, packet loss, upstream bandwidth, frame rate) and tells you the limiting factor. Level is debounced; metrics refresh each stats sample (\~few seconds):

```kotlin theme={null}
realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        onConnectionQuality = { report ->
            // report.limitingFactor: BANDWIDTH | LATENCY | LOSS | STALL | CPU | NONE
            // report.metrics: rttMs, fps, packetLoss, availableUpstreamKbps, ...
            updateBadge(report.quality)
        },
        onRemoteStream = { /* ... */ },
    ),
)

// also a Flow + a getter:
realtime.connectionQuality.collect { /* ConnectionQualityReport? */ }
realtime.getConnectionQuality() // latest, or null before the first sample
```

### Glass-to-glass latency (opt-in)

Network RTT alone doesn't reflect the latency users actually feel — a session can read `GOOD` while still feeling laggy. Set `debugQuality = true` to measure the real camera→display latency: the SDK stamps a pixel marker into each outgoing frame and reads it back off the rendered output, surfacing startup (`ttffMs`), steady-state (`g2gMs`), and end-to-end drops (`g2gDropRatio`). When present, glass-to-glass drives the latency verdict instead of RTT.

```kotlin theme={null}
val stream = realtime.createLocalVideoStream(
    model = RealtimeModels.LUCY_2_1,
    debugQuality = true,
)
realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        debugQuality = true,
        onConnectionQuality = { report ->
            // report.metrics.ttffMs / g2gMs / g2gDropRatio
        },
    ),
    localStream = stream,
)
```

<Warning>Diagnostic only. The marker is visible (bottom-left of the published + rendered video) and adds per-frame pixel work — don't enable it for production / end-user sessions. With a caller-provided stream, build it via `createLocalVideoStream(..., debugQuality = true)` so the same flag is set on both the stream and `connect()`.</Warning>

### Deep preflight

For a *measured* verdict before connecting, use the deep probe — it briefly opens a real session with a synthetic source, measures glass-to-glass, then tears it down. Requires a `model` and costs a short session:

```kotlin theme={null}
import ai.decart.sdk.realtime.CheckConnectivityOptions

val probe = realtime.checkConnectivity(
    CheckConnectivityOptions(deep = true, model = RealtimeModels.LUCY_2_1),
)
// probe.metrics.g2gMs / ttffMs / g2gDropRatio
```

## Session Identifiers

Once a session is established the SDK surfaces the server-side session id and a subscribe token:

```kotlin theme={null}
val sessionId = realtime.sessionId        // for logging / analytics
val subscribeToken = realtime.subscribeToken // share with viewers for read-only watching
```

Both come from the `sessionStarted: StateFlow<SessionStarted?>` — collect it if you need to react when they appear.

## Cleanup

Always tear down the session and release native resources when you're done:

```kotlin theme={null}
// If you own the local stream, dispose it after the client teardown.
realtime.disconnect()
localStream?.dispose()
client.release()
```

`disconnect()` ends the current session but leaves the client reusable. `release()` cancels the SDK's coroutine scope and shuts down the underlying LiveKit room ownership; call it when the client is no longer needed.

<Warning>Forgetting to `dispose()` a caller-owned `RealtimeMediaStream` leaks the LiveKit Room (and its capturer + native PeerConnectionFactory). Tie disposal to your lifecycle (`onCleared`, `DisposableEffect`, etc.).</Warning>

## Complete Jetpack Compose Example

A full Jetpack Compose tab with a caller-owned preview and the LiveKit renderer, modeled on the [sample app on GitHub](https://github.com/DecartAI/decart-android/tree/main/sample):

```kotlin theme={null}
import ai.decart.sdk.*
import ai.decart.sdk.realtime.*
import android.Manifest
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.Room
import io.livekit.android.room.track.VideoTrack
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import livekit.org.webrtc.RendererCommon

@Composable
fun RealtimeScreen(apiKey: String) {
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()

    var connectionState by remember { mutableStateOf(ConnectionState.DISCONNECTED) }
    var prompt by remember { mutableStateOf("Turn into a fantasy figure") }
    val selectedModel = RealtimeModels.LUCY_2_1

    // Caller-owned preview, created lazily and torn down on dispose.
    var localStream by remember { mutableStateOf<RealtimeMediaStream?>(null) }
    var remoteStream by remember { mutableStateOf<RealtimeMediaStream?>(null) }
    var client by remember { mutableStateOf<DecartClient?>(null) }

    LaunchedEffect(selectedModel) {
        localStream?.dispose()
        localStream = null
        val tmp = DecartClient(context, DecartClientConfig(apiKey = apiKey))
        localStream = tmp.realtime.createLocalVideoStream(
            model = selectedModel,
            facing = FacingMode.FRONT,
            mirror = MirrorMode.AUTO,
        )
        tmp.release() // we only used it to build the preview
    }

    LaunchedEffect(client) {
        val c = client ?: return@LaunchedEffect
        launch { c.realtime.connectionState.collect { connectionState = it } }
        launch {
            c.realtime.remoteStreamUpdates.collectLatest { remoteStream = it }
        }
        launch {
            c.realtime.errors.collect { Log.e("Decart", it.message ?: "error") }
        }
    }

    DisposableEffect(Unit) {
        onDispose {
            client?.release()
            client = null
            localStream?.dispose()
            localStream = null
        }
    }

    val isConnected = connectionState == ConnectionState.CONNECTED ||
        connectionState == ConnectionState.GENERATING

    val permissionLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (!granted) return@rememberLauncherForActivityResult
        val preview = localStream ?: return@rememberLauncherForActivityResult
        val newClient = DecartClient(context, DecartClientConfig(apiKey = apiKey))
        client = newClient
        coroutineScope.launch {
            try {
                newClient.realtime.connect(
                    options = ConnectOptions(
                        model = selectedModel,
                        initialPrompt = InitialPrompt(prompt, enhance = true),
                        facing = FacingMode.FRONT,
                        publishCamera = true,
                    ),
                    localStream = preview,
                )
            } catch (e: Exception) {
                Log.e("Decart", "connect failed", e)
            }
        }
    }

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        // Remote video
        Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
            LiveKitVideoView(
                track = remoteStream?.videoTrack,
                room = remoteStream?.room ?: localStream?.room,
                modifier = Modifier.fillMaxSize(),
            )
        }

        Text("Status: $connectionState")

        Row(modifier = Modifier.fillMaxWidth()) {
            OutlinedTextField(
                value = prompt,
                onValueChange = { prompt = it },
                label = { Text("Style prompt") },
                modifier = Modifier.weight(1f),
            )
            Spacer(modifier = Modifier.width(8.dp))
            Button(
                onClick = {
                    coroutineScope.launch {
                        try {
                            client?.realtime?.setPrompt(prompt, enhance = true)
                        } catch (e: Exception) {
                            Log.e("Decart", "setPrompt failed", e)
                        }
                    }
                },
                enabled = isConnected,
            ) { Text("Send") }
        }

        Button(
            onClick = {
                if (isConnected) {
                    client?.realtime?.disconnect()
                    client?.release()
                    client = null
                } else {
                    permissionLauncher.launch(Manifest.permission.CAMERA)
                }
            },
            modifier = Modifier.fillMaxWidth(),
        ) { Text(if (isConnected) "Disconnect" else "Connect") }
    }
}

@Composable
fun LiveKitVideoView(
    track: VideoTrack?,
    room: Room?,
    modifier: Modifier = Modifier,
) {
    if (room == null) return
    val rendererRef = remember { mutableStateOf<TextureViewRenderer?>(null) }
    val boundTrack = remember { mutableStateOf<VideoTrack?>(null) }

    AndroidView(
        modifier = modifier,
        factory = { ctx ->
            TextureViewRenderer(ctx).also { r ->
                r.init(room.lkObjects.eglBase.eglBaseContext, null)
                r.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
                rendererRef.value = r
                track?.addRenderer(r)
                boundTrack.value = track
            }
        },
        update = { r ->
            if (boundTrack.value !== track) {
                boundTrack.value?.removeRenderer(r)
                track?.addRenderer(r)
                boundTrack.value = track
            }
        },
    )

    DisposableEffect(Unit) {
        onDispose {
            boundTrack.value?.removeRenderer(rendererRef.value ?: return@onDispose)
            try { rendererRef.value?.release() } catch (_: Exception) {}
        }
    }
}
```

## Best Practices

<AccordionGroup>
  <Accordion title="Let the model registry size the camera">
    Use `realtime.createLocalVideoStream(model, ...)` (or the static factory) so capture dimensions match the model exactly. Lucy 2.1 / VTON models capture at `1088x624`; Lucy Restyle 2 at `1280x704`.
  </Accordion>

  <Accordion title="Use suspend `setPrompt` from a coroutine">
    `setPrompt` and `setImage` are `suspend` functions that wait for the server ack and throw on ack failure, timeout, or disconnect. Always call them from a coroutine (`lifecycleScope`, `viewModelScope`, `rememberCoroutineScope()`) and handle exceptions. Reach for `setPromptAsync` / `setImageAsync` only when you need a `Deferred` handle.
  </Accordion>

  <Accordion title="Enable prompt enrichment">
    Keep `enhance = true` (default) so Decart's AI expands simple prompts. Disable it only when you need exact prompt control.
  </Accordion>

  <Accordion title="Re-attach the renderer on auto-reconnect">
    `remoteStreamUpdates` emits a fresh `RealtimeMediaStream` after each reconnect. Remove the previous track from your renderer and add the new one — failing to do so leaves the renderer bound to a dead track.
  </Accordion>

  <Accordion title="Dispose caller-owned streams">
    If you call `createLocalVideoStream` yourself, dispose the returned `RealtimeMediaStream` from your lifecycle hook (`onCleared`, `DisposableEffect.onDispose`). The Room owns native resources that don't get freed otherwise.
  </Accordion>

  <Accordion title="Test on real devices">
    Always test camera features on real Android devices. The emulator does not support camera capture for realtime sessions.
  </Accordion>

  <Accordion title="Request permissions properly">
    Request `CAMERA` permission at runtime before attempting to connect. (Realtime audio is not currently supported, so `RECORD_AUDIO` isn't required.) Handle permission denials gracefully in your UI.
  </Accordion>
</AccordionGroup>

## API Reference

### `DecartClient(context, config)`

Top-level entry point. Exposes `realtime: RealTimeClient` and `queue: QueueClient`. Use `DecartClientConfig(apiKey, baseUrl?, httpBaseUrl?, logLevel?)`. Call `client.release()` to tear down both sub-clients.

### `realtime.createLocalVideoStream(model, facing, mirror, configuration?, includeMicrophone?)`

Build a caller-owned preview `RealtimeMediaStream` sized to `model.width` × `model.height`. Pass it to `connect(..., localStream = ...)` so preview and publish share a LiveKit `Room`. Caller must `dispose()` the stream.

**Parameters:**

* `model: RealtimeModel` - Model to size the capture for
* `facing: FacingMode` - `FRONT` (default) or `BACK`
* `mirror: MirrorMode` - `AUTO` (default), `OFF`, or `ON`
* `configuration: RealtimeConfiguration` (optional) - Codec / bitrate / framerate overrides
* `includeMicrophone: Boolean` - Ignored; audio is not yet supported

Overload: `createLocalVideoStream(width, height, facing, ...)` takes explicit dimensions instead of a model. Static `RealTimeClient.createLocalVideoStream(context, model, ...)` lets you build a preview before instantiating a client.

### `realtime.connect(options, localStream?)`

Suspend. Join the model's LiveKit room and (by default) publish the local camera. Returns the remote `RealtimeMediaStream` and also emits it via `onRemoteStream` / `remoteStreamUpdates`.

**`ConnectOptions` parameters:**

* `model: RealtimeModel` (required) - Realtime model from `RealtimeModels`
* `initialPrompt: InitialPrompt?` - Starting prompt (text + enhance)
* `initialImage: String?` - Base64-encoded reference image
* `resolution: Resolution?` - `P720` or `P1080`. Omit for the server's 720p default; pass `P1080` to request 1080p from supported models.
* `realtimeConfiguration: RealtimeConfiguration` - Codec / bitrate / framerate tuning
* `publishCamera: Boolean` - Open the camera and publish (default `true`)
* `publishMicrophone: Boolean` - Ignored in 0.7; audio is not supported
* `facing: FacingMode` - `FRONT` (default) or `BACK`. Only used when the SDK opens its own camera.
* `mirror: MirrorMode` - `AUTO` (default), `OFF`, or `ON`
* `onRemoteStream: ((RealtimeMediaStream) -> Unit)?` - Callback when a (re)connected remote stream is available

**Throws** on signaling, ICE, or LiveKit room failures.

### `realtime.setPrompt(prompt, enhance, timeoutMs)`

Suspend. Update the prompt and wait for the server ack.

**Parameters:**

* `prompt: String` - Style description
* `enhance: Boolean` - Auto-enhance the prompt (default: `true`)
* `timeoutMs: Long` - Ack timeout in ms (default: `15_000`)

**Throws** `IllegalStateException` if not connected, or a `DecartError` on ack failure, timeout, or websocket disconnect.

### `realtime.setPromptAsync(prompt, enhance, timeoutMs)`

Same parameters as `setPrompt` but returns `Deferred<Unit>`. Call `await()` to observe ack failure / timeout / disconnect.

### `realtime.setImage(imageBase64, prompt, enhance, timeout)`

Suspend. Send (or clear, with `null`) a reference image.

**Parameters:**

* `imageBase64: String?` - Base64-encoded image, or `null` to clear
* `prompt: String?` - Optional text prompt
* `enhance: Boolean?` - Whether to enhance the prompt
* `timeout: Long` - Ack timeout in ms (default: `30_000`)

**Throws** `IllegalStateException` if not connected.

### `realtime.setImageAsync(imageBase64, prompt, enhance, timeout)`

Same parameters as `setImage` but returns `Deferred<Unit>`.

### `realtime.disconnect()` / `realtime.release()`

`disconnect()` ends the current session. `release()` cancels the SDK's coroutine scope; call it when the client is no longer needed (and prefer `client.release()` to also clean up the queue client).

### `realtime.isConnected()`

**Returns:** `Boolean` - Whether the current connection state is `CONNECTED` or `GENERATING`.

### `RealtimeMediaStream`

* `videoTrack: VideoTrack?` - LiveKit video track; pass to your renderer via `addRenderer(...)` / `removeRenderer(...)`.
* `audioTrack: AudioTrack?` - Deprecated; always `null` in 0.7.
* `id: String` - Stream identifier (`stream-local` or `stream-remote`).
* `room: Room?` - LiveKit room that owns the tracks. Use `room.lkObjects.eglBase.eglBaseContext` when initializing renderers.
* `dispose()` - Tear down tracks and the room. Safe to call multiple times.

### Observable State

| Property               | Type                                 | Description                                                      |
| ---------------------- | ------------------------------------ | ---------------------------------------------------------------- |
| `connectionState`      | `StateFlow<ConnectionState>`         | Current connection state                                         |
| `connectionChange`     | `StateFlow<ConnectionState>`         | JS-aligned alias for `connectionState`                           |
| `errors`               | `SharedFlow<DecartError>`            | Error events                                                     |
| `generationTicks`      | `SharedFlow<GenerationTickMessage>`  | Per-second tick during generation                                |
| `generationEnded`      | `SharedFlow<GenerationEndedMessage>` | Generation lifecycle end events                                  |
| `queuePositionUpdates` | `SharedFlow<QueuePositionMessage>`   | Queue position updates while waiting for a server slot           |
| `localStreamUpdates`   | `SharedFlow<RealtimeMediaStream>`    | Local LiveKit stream updates                                     |
| `remoteStreamUpdates`  | `SharedFlow<RealtimeMediaStream>`    | Remote LiveKit stream updates (including auto-reconnect rebinds) |
| `sessionStarted`       | `StateFlow<SessionStarted?>`         | `(sessionId, subscribeToken)` once the LiveKit room info arrives |
| `subscribeToken`       | `String?`                            | Token to hand to viewer / subscribe clients                      |
| `sessionId`            | `String?`                            | Current session id                                               |
| `diagnostics`          | `SharedFlow<DiagnosticEvent>`        | Connection diagnostic events                                     |
| `stats`                | `SharedFlow<PublishStatsEvent>`      | Publisher outbound video stats                                   |

## Next Steps

<CardGroup cols={2}>
  <Card title="Queue API" icon="layer-group" href="/sdks/android-queue">
    Generate and transform videos with batch processing and Flow-based progress
  </Card>

  <Card title="SDK Overview" icon="android" href="/sdks/android">
    Installation, setup, and Android SDK fundamentals
  </Card>
</CardGroup>
