Quincer AI Docs Home Support Start free

Get started

React Native SDK

@quincer/react-native embeds Quincer AI in iOS and Android apps with the same agent, the same dashboard, and the same integrations as the web widget. SSE streaming chat, native voice, visitor identity, transcript restore across reinstalls, and live human-agent takeover — all from one provider mount.

i

Current release: v0.1.0-alpha.0. Targets bare React Native 0.74+. Expo support is on the v0.2 roadmap. Agent-handoff over WebRTC SFU lands in v0.3. Until then, agent text replies during takeover are streamed over the takeover SSE channel.

Quickstart

  1. Get a widget key. In the dashboard open Widget → Deploy and copy the cw_live_… value. Mobile apps and the web widget share the same key — one brand, one key, all surfaces.
  2. Install the SDK and its peer dependencies.
    npm install @quincer/react-native \
                @react-native-async-storage/async-storage \
                react-native-sse
    cd ios && pod install
  3. Mount the provider at your app root. Drop the launcher anywhere, or call the imperative API.
    import { QuincerProvider, QuincerLauncher, Quincer } from "@quincer/react-native";
    
    export default function App() {
      return (
        <QuincerProvider config={{ widgetKey: "cw_live_..." }}>
          <YourApp />
          <QuincerLauncher position="bottom-right" />
        </QuincerProvider>
      );
    }
  4. Apply the platform-specific config below. iOS needs an NSMicrophoneUsageDescription string; Android needs the autolinked permissions to merge in. Skip the iOS step if you don't plan to use voice yet.
  5. Run on a real device. yarn ios or yarn android. The simulator works for text, but voice needs real hardware — simulator mic capture is unreliable at 24kHz PCM16.

A copy-pasteable smoke-test app lives at packages/react-native/example/ in the GitHub repo. Run ./bootstrap.sh from there and it scaffolds a fresh bare-RN host with the SDK pre-wired.

iOS setup

Open ios/<YourApp>/Info.plist and add the microphone usage string. Without it, iOS will crash the app the first time you call Quincer.startVoice() — not just deny permission. The string you set is what shows up in the system permission prompt, so write it for your end user.

<key>NSMicrophoneUsageDescription</key>
<string>Used for voice calls with the assistant.</string>

Optional: keep voice calls alive during app-switch

By default, iOS suspends audio when the user switches apps mid-call. To let voice survive a quick app-switch (e.g. checking calendar), declare the audio background mode:

<key>UIBackgroundModes</key>
<array>
  <string>audio</string>
</array>
!

App Store review will ask why you need the audio background mode. The honest answer is “voice calls with the in-app assistant continue when the user briefly switches to another app.” If you don't plan to use voice, skip this — declaring background modes you don't use is grounds for rejection.

Android setup

The SDK declares RECORD_AUDIO, INTERNET, and MODIFY_AUDIO_SETTINGS in its own AndroidManifest.xml; manifest merging brings them into your app automatically. You don't need to touch android/app/src/main/AndroidManifest.xml.

On Android 12+ the SDK handles the runtime mic permission prompt the first time Quincer.startVoice() runs. If the user denies, the SDK fires a voiceClosed event with reason permission_denied — surface that in your UI.

Visitor identity & persistence

Identity in the SDK has three modes — pick the one that matches how much you know about the user.

Anonymous (default)

Without an identify() call, the SDK generates a stable anonymous visitorId on first launch and persists it in AsyncStorage. The same user gets the same id across app launches but a fresh id after uninstall — fine for marketing-site-style flows.

Signed-in visitor

When your user signs in to your app, hand the SDK an authoritative visitorId plus optional profile fields:

Quincer.identify({
  visitorId: "user_42",          // your stable user ID
  name: "Alex",
  email: "[email protected]",
  imageUrl: "https://...",
});

This unlocks server-side transcript restore (see below) — the same user sees the same conversation history on every device.

Verified visitor (with JWT)

For trusted-visitor flows where the agent should see things like “this user is a Pro subscriber, has 3 active tickets, last login was 2 days ago”, issue a short-lived JWT from your backend and pass it as token:

Quincer.identify({
  visitorId: "user_42",
  name: "Alex",
  token: "<signed JWT, sub: user_42>",  // forwarded as Authorization: Bearer
});

See Visitor sign-in for the JWT signing contract — the SDK uses the same payload shape as the web widget. Without a verified token, sensitive operator tools (account lookup, ticket actions) refuse to fire.

What persists where

AsyncStorage keyHoldsCleared by
quincer.visitorId.v1 Anonymous fallback visitor id. App uninstall only.
quincer.conversationId.v1 Last server conversation id, restored on relaunch. Quincer.resetConversation().
quincer.identity.v1 Identity blob from the most recent identify(). Quincer.identify({}) with the empty object, or app uninstall.
quincer.widgetConfig.v1 15-minute cache of /api/widget/config (brand, theme, personas). 15-minute TTL, or app uninstall.

Call Quincer.resetConversation() to forget the conversation but keep visitor identity (useful for a “start fresh” affordance in your UI).

Voice

Voice runs entirely native on both platforms — iOS uses AVAudioEngine with AVAudioConverter to produce 24kHz/PCM16/mono frames; Android uses AudioRecord and AudioTrack on the same format. The over-the-wire protocol matches the JS widget byte-for-byte (the same OpenAI-Realtime envelope), so you can side-by-side the mobile and web flows to debug behavioural drift.

Start, mute, end

// Request mic, open the voice sheet, connect the relay.
Quincer.startVoice();

// Subscribe to state transitions.
Quincer.on("voiceStateChange", (state) => {
  // "requestingPermission" | "connecting" | "buffering" | "listening" |
  // "speaking" | "error" | "ended"
});

// Pause input + pause the relay's silence timer.
Quincer.setVoiceMuted(true);

// Hang up cleanly. Optional reason; defaults to "user_ended".
Quincer.endVoice();

Live transcript

Voice turns are pushed both to a dedicated voiceTranscript event AND into the chat history, so after a call your chat view shows what was said.

Quincer.on("voiceTranscript", (turn) => {
  // turn.role: "user" | "assistant"
  // turn.text: final text for the turn
});

Barge-in

The user can interrupt the assistant mid-response by speaking — barge-in is handled natively. Playback stops within ~200 ms, state returns to listening. No extra wiring on your end.

Close-reason codes

When voice ends, the SDK fires a voiceClosed event with one of seven reasons. Map them to your UX:

ReasonWhat happenedSuggested UX
user_endedThe visitor tapped End.Dismiss sheet silently.
permission_deniedMic permission refused.Show how to re-enable in Settings.
cap_exhaustedOrg hit its monthly voice-minute cap.Tell the user to use text chat; surface to the operator that an upgrade is needed.
silence_timeoutNo audio in either direction for too long.Friendly “Are you still there?” toast before closing.
max_durationCall hit the per-call cap (default 10 min).Offer to continue in text.
relay_errorVoice-relay WebSocket dropped unexpectedly.Retry once; if it fails again, fall back to text.
network_errorDevice lost connectivity.Surface offline state; the chat history is preserved.
Quincer.on("voiceClosed", ({ reason, message }) => {
  if (reason === "permission_denied") openSettingsPrompt();
  else if (reason === "cap_exhausted") showUpgradePrompt(message);
});

Transcript restore & live agent takeover

Both features are powered by widget-key conversation endpoints (/api/widget/conversations/[id]/messages and /api/widget/conversations/[id]/stream). The SDK handles them for you — no extra calls required.

Cross-device transcript restore

On every launch, if the SDK has a stored conversationId but no local messages (fresh install, new device, app data wiped), it fetches the server transcript and rehydrates the chat. The visitor sees their history even after uninstalling and reinstalling — provided you call identify() with a stable visitorId.

i

Ownership check. The server only returns transcripts where conversation.visitorId === request.visitorId. When the widget has visitorAuthMode set in the dashboard, the SDK additionally forwards your JWT and the server requires its sub claim to match.

Live human-agent takeover

When a teammate clicks Take over in the dashboard (or an external agent claims via the OpenClaw integration), the AI stops responding and a human types instead. The SDK detects this:

  1. POST /api/chat returns escalationPending: true in its response.
  2. The SDK opens a Server-Sent Events stream to /api/widget/conversations/[id]/stream.
  3. Agent messages, status changes, and takeover/handback events arrive on the stream and render in the chat view live — no polling, no refresh.
  4. If the user comes back to the app mid-takeover, the SDK reopens the stream automatically based on the restored conversation state.
  5. The server closes the stream after 10 minutes (lifetime cap); the SDK reconnects transparently if the conversation is still in takeover.

Nothing to wire on your end — this is part of the default chat surface. See Live chat for the operator-side flow.

Imperative API

Anywhere below the provider, call methods on Quincer:

MethodBehavior
Quincer.open()Present the chat (modal sheet by default).
Quincer.close()Dismiss the chat.
Quincer.toggle()Toggle open state.
Quincer.identify(i)Set / update visitor identity. Persists across launches.
Quincer.sendMessage(t)Send a message programmatically (also opens the chat).
Quincer.resetConversation()Clear local messages + server conversationId.
Quincer.startVoice()Request mic, fetch ticket, open WebSocket, present voice sheet.
Quincer.endVoice(reason?)Close the voice session.
Quincer.setVoiceMuted(bool)Pause/resume mic capture.
Quincer.on(event, cb)Subscribe to message, open, close, voiceStateChange, voiceTranscript, voiceClosed, error.

Prefer to build your own UI? useQuincer() returns { state, actions } — ignore QuincerLauncher and drive the surface yourself.

Configuration

FieldTypeRequiredNotes
widgetKeystringyescw_live_… from the dashboard.
apiUrlstringnoDefaults to https://chat.quincer.com/api. Override for self-hosted.
pageUrlstringnoLogical “page” identifier for persona URL-pattern routing.
personaIdstringnoPin the conversation to a specific persona.
streambooleannoSSE streaming on (default) / off.

Visual theming (colors, brand, tagline, welcome message) is loaded from your dashboard via /api/widget/config and applied automatically. Native layout — sheets, FlatList scrolling, keyboard handling, safe areas — follows platform idioms.

Troubleshooting

SSE responses arrive in one drop instead of streaming

Make sure you have react-native-sse installed and that your network layer isn't buffering responses. Some corporate proxies and a few iOS VPN profiles collapse SSE into one buffered chunk — if you can't test over LTE, switch to a different proxy.

Conversation doesn't restore after reinstall

Server-side restore requires (1) the user to be identify()’d with the same visitorId you used pre-uninstall, and (2) the widget's visitorAuthMode — if set — to receive a valid JWT whose sub matches that id. Anonymous fallback ids are regenerated on uninstall by design (we have no way to recognize the device).

Voice never gets past requestingPermission

On iOS, double-check NSMicrophoneUsageDescription is present in Info.plist — missing it crashes the permission prompt silently in some iOS versions. On Android 12+, the user may have permanently denied the permission; surface a prompt that deep-links into Settings → App info → Permissions.

Voice works on real device, fails on simulator

Expected. iOS Simulator's mic capture is unreliable at 24kHz PCM16; Android Emulator's mic is broken by default and needs host-audio passthrough enabled in AVD config. Always validate voice on real hardware.

JWT signature mismatch on the server

The visitor JWT must be signed with the same secret you set under Dashboard → Visitor sign-in. The sub claim must match the visitorId you pass to identify(). Check server logs for the specific failure (invalid signature vs sub mismatch are different problems with different fixes).

Live agent takeover never starts

Confirm POST /api/chat is returning escalationPending: true when an operator takes over (check the response body in your network inspector). If it is and the SSE stream doesn't connect, you're likely behind a proxy that strips Last-Event-ID headers — same fix as the streaming case above.

Multiple QuincerProvider instances

Don't. Mount one at the app root and call Quincer.* from anywhere below. Two providers will fight over the imperative API, the AsyncStorage keys, and the voice session state machine.

What's coming next

See the SDK's README on GitHub for engineering details and the phased plan in docs/plans/mobile-rn-sdk.md.

SDK changelog

Notable SDK and widget-key API changes — the source of truth for what an integrating app might need to update against.

DateChangeAffects integrating apps?
2026-05-18 SDK v0.1.0-alpha.0 ships. GET /api/widget/conversations/[id]/messages and GET /api/widget/conversations/[id]/stream added as widget-key endpoints to power transcript restore and live agent takeover. No — new features, additive. Self-hosted deployments without the new endpoints degrade gracefully (no restore, no takeover stream) but text + voice still work.