Lifecycle
The session lifecycle is identical to the web widget. The single most important behavior to get right:
minimize() is UI-only — the session and WebSocket stay alive. endSession() ends the session
(sends {type:"end"}) and tears everything down. In your custom UI, wire your "collapse" control to
minimize() and your "X" to endSession().
The lifecycle table
| You do | What happens on the wire | Backend |
|---|---|---|
startVoiceSession() / startTextSession() | issue token → open WS → handshake (ready) | ready → session_state |
minimize() | nothing — UI-only collapse; WS + session stay alive, no end sent | session stays active |
restore() | nothing — un-collapse | — |
| app backgrounded | WS may drop → 60s reattach window | mark_widget_disconnect, TTL 60s |
| → return < 60s | reconnect to the same conversation_id → resume | reattach_session resumes Gemini |
| → return > 60s | a fresh session, prior context recapped | pause_timeout → end_session, new session |
endSession() | sends {type:"end", reason:"user_closed"} → real end + teardown | end_session(reason="client_end") |
| bot ends / fatal close | SDK emits ended / a failed state, tears down | end_session(...) |
screen unmount / destroy() | hard teardown (no end sent) | client_disconnect |
Backgrounding → 60s reattach
When the app is backgrounded the OS may drop the socket. The backend keeps the Redis session state alive for 60 seconds:
- Return within 60s → the SDK reconnects to the same
conversation_idand the same Gemini session resumes. The retained app context (see Context enrichment) is re-sent automatically. - Return after 60s → the SDK starts a fresh session; the backend recaps prior context from the database so the conversation continues coherently.
This is not an end. You never send end on backgrounding.
foreground ──tap launcher──▶ session live (WS open)
│
├─ minimize() ──────────────────────────────▶ session live (UI collapsed, WS open, NO end)
│
├─ app backgrounded ──▶ WS drops ──┬─ return <60s ─▶ reattach same conversation_id (resume)
│ └─ return >60s ─▶ fresh session + recap
│
└─ endSession() ─────────────────────────────▶ {type:"end"} ─▶ real end + teardown
Reconnect policy
The transport reconnects on transient drops with exponential backoff + jitter, capped at ≤3 attempts, then settles into a graceful state without crashing. A heartbeat ping runs about every 25s; if no pong is seen in ~30s the SDK closes and reconnects.
Fatal close codes are NOT retried — they mean the session cannot continue:
| Code | Meaning |
|---|---|
4400 | malformed request |
4401 | invalid/expired token (or key blocked by the billing kill-switch) |
4403 | app_id not allowed |
4503 | voice provider down |
1008 | session limit |
1011 | server crash |
All other drops are reconnected (within the ≤3 cap). See the wire protocol §8.
Observing state
- React Native
- Flutter
const session = await client.startVoiceSession({ lang: 'uk' });
session.on('status', (s) => console.log(s.status)); // idle | connecting | … | ended
session.on('ended', (e) => console.log('ended:', e.reason));
session.on('error', (e) => console.warn(e.code, e.message));
session.minimize(); // UI-only — session stays alive
session.restore();
await session.endSession(); // the X — real end
session.destroy(); // hard teardown on unmount (no end sent)
final session = client.startVoiceSession(config: const SessionConfig(lang: 'uk'));
// Coarse status, or the sealed states for richer handling.
session.status.listen((s) => print(s.name));
session.states.listen((s) {
// SessionConnecting | SessionReady | SessionReconnecting | SessionEnded | SessionFailed
});
session.minimize(); // UI-only — session stays alive
session.restore();
await session.endSession(); // the X — real end
await session.dispose(); // hard teardown
await client.destroy();
Parity note. The web widget does not yet wire an explicit "X to end" button (the backend frame exists but is unwired on web today). The mobile SDKs expose it as
endSession().