Skip to main content
Version: next

Lifecycle

The session lifecycle is identical to the web widget. The single most important behavior to get right:

minimize ≠ close

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 doWhat happens on the wireBackend
startVoiceSession() / startTextSession()issue token → open WS → handshake (ready)readysession_state
minimize()nothing — UI-only collapse; WS + session stay alive, no end sentsession stays active
restore()nothing — un-collapse
app backgroundedWS may drop → 60s reattach windowmark_widget_disconnect, TTL 60s
→ return < 60sreconnect to the same conversation_id → resumereattach_session resumes Gemini
→ return > 60sa fresh session, prior context recappedpause_timeoutend_session, new session
endSession()sends {type:"end", reason:"user_closed"} → real end + teardownend_session(reason="client_end")
bot ends / fatal closeSDK emits ended / a failed state, tears downend_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_id and 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:

CodeMeaning
4400malformed request
4401invalid/expired token (or key blocked by the billing kill-switch)
4403app_id not allowed
4503voice provider down
1008session limit
1011server crash

All other drops are reconnected (within the ≤3 cap). See the wire protocol §8.

Observing state

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)

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().