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 Customize & Deploy, keep the Manual embed tab selected, and read the key out of the generated Embed code snippet — it’s the apiKey value (the panel below). The collapsible API keys section has a one-click copy button for the raw key, or Generate API key if the widget has none yet. 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.
Customize & Deploy
×
Manual embedShopify
Embed code

Paste this snippet before the closing </body> tag of your website.

<script> window.ChatWidgetConfig = { apiKey: "cw_live_a1b2c3d4e5f6", apiUrl: "https://chat.quincer.com/api" }; </script> <script src="https://chat.quincer.com/api/embed/widget.js" defer></script>
This is your widget key: cw_live_a1b2c3d4e5f6
API keys
Copy the raw key, or generate a new one
Copy

The Customize & Deploy panel — the apiKey in the Embed code snippet is the same cw_live_… key you pass to QuincerProvider.

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.

You configure the verifier once, in the dashboard, under Integrations → Visitor sign-in (the panel below). The Mode card picks how tokens are checked — JWT + JWKS if your auth backend (Auth0, Cognito, Clerk) serves a JWKS URL, or HMAC shared secret if your backend signs with HS256 and a secret you paste or Generate here. The Test a token card lets you paste a real token and hit Verify to see exactly the sub and claims the AI will receive — the same sub you must pass as visitorId to identify().

Visitor sign-in
×
Mode

Pick how your platform identifies the visitor to the widget.

JWT + JWKS Recommended
Your platform issues signed JWTs (Auth0, Cognito, Clerk) and serves a JWKS endpoint.
HMAC shared secret
Your backend signs a JWT with HS256 + a shared secret. Simpler with no public-key setup.
Test a token
eyJhbGciOiJIUzI1NiIs…
Refresh JWKS + verifyVerify

The Visitor sign-in panel — set the verifier Mode, then Verify a sample token to confirm the sub matches the visitorId you pass to identify().

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.

Persona handoff & two-bubble replies

When the responding persona hands the conversation to a different persona (for example, a sales agent routing a billing question to a support persona), a single POST /api/chat send can render two bubbles: the source persona's hand-off line, then the arriving persona's self-introduction. Each bubble carries its own persona name and bubbleColor, so the tint changes mid-conversation. The SDK handles both delivery paths automatically:

i

Per-persona bubble color. A persona's bubbleColor colors that persona's chat/message bubbles — not the header, launcher, or overall theme. Each bot message is tinted to the responding persona's bubbleColor (with contrast-adjusted text), falling back to the theme's default bot bubble when a persona sets no color. Because this is per message, the color changes correctly across a persona handoff. This matches the web widget's behavior.

i

Persona avatars. Personas returned by /api/widget/config now include a resolved avatarUrl (a preset image path, an uploaded/generated image URL, or null), and each /api/chat frame carries a personaAvatarUrl (the streaming done frame, both sides of the handoff frame — sourcePersonaAvatarUrl and personaAvatarUrl — and the non-streaming secondMessage). MessageBubble renders the persona's avatar image next to its bubble, absolutizing a relative URL against your configured base URL, and falls back to the persona's initials on its bubbleColor when no avatar is set. Nothing to wire on your end — the SDK renders it automatically.

Both paths are handled inside the default chat surface — nothing to wire on your end. If you build a custom UI with useQuincer(), watch for the extra bubble arriving via the "message" event.

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.
pageUrlstringnoCurrent page URL (full URL or path). Drives 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-08-22 New widget-key endpoint: POST /api/widget/visitor-info, and a new ui frame kind. When a brand requires identity details, the web widget now renders a small form ({type:"ui", ui:{kind:"visitorInfo", …}}) and submits it to this endpoint instead of the persona asking question by question. Field labels, buttons and a dir for right-to-left arrive already localized to the visitor’s language. The server sends the frame only to clients known to draw it (currently the web widget), so this SDK receives nothing new and its unknown-frame handling is unchanged — on mobile the persona keeps collecting details conversationally. Adopting it is opt-in on both sides: render the envelope, POST the values, and add your channel server-side. No — additive; nothing is sent to this SDK until it opts in.
2026-08-06 Meeting slot payloads no longer carry attendee addresses. The _ui object streamed by POST /api/chat for a kind: "slots" result previously included an attendees array of every staff email on the meeting; it now sends attendeeCount (a number) instead, because those addresses were being delivered to the visitor's device for no reason other than to be counted. The first-party widget reads attendeeCount and falls back to attendees.length — that fallback is for a NEW bundle talking to an OLD server, not the reverse. A cached bundle from before this release sees only attendees, which the server no longer sends, so it quietly drops the “N attendees” line until it refreshes. If you read attendees in your own app, switch to attendeeCount.
Same release: the booker now resolves meeting times from per-location and per-person booking hours rather than a fixed 9am–5pm window, so the slots an app receives can fall outside business hours where the visitor is — the accompanying message says so. No SDK API changed.
Only if your app reads _ui.attendees directly. Read _ui.attendeeCount; the addresses are no longer sent.
2026-08-06 Model selection is now limited to your own provider keys. Models reachable only through Quincer’s shared platform keys can no longer be pinned to a widget — those keys serve the platform default model, which a widget uses by leaving its model unset. POST /api/chat is unchanged in shape and behaviour; a widget still answers on whatever model it resolves to at request time. Add your own provider API key to choose a specific model. No
2026-07-29 Fixed: conversations could not continue past the first message. POST /api/chat requires a per-conversation visitor token whenever the request carries a conversationId; the SDK never sent it, so the second message in any conversation returned 403 conversation_forbidden. The SDK now captures conversationToken from the response that creates the conversation (streaming done and non-streaming alike), stores it as quincer.conversationToken.v1, and sends it on every later message. Yes — update to get multi-turn chat working at all. No code change in your app; the fix is inside the SDK. If you built your own client against /api/chat, you must capture and resend conversationToken yourself.
2026-07-05 Persona avatars: personas returned by /api/widget/config now include a resolved avatarUrl, and each /api/chat frame carries a personaAvatarUrl (streaming done; both sourcePersonaAvatarUrl + personaAvatarUrl on handoff; and non-streaming secondMessage). MessageBubble renders the persona avatar image (relative URLs absolutized against the base URL) with an initials-on-bubbleColor fallback. No — additive; the SDK renders it automatically. Personas with no avatar keep the existing initials fallback.
2026-07-04 Persona handoff on POST /api/chat: a new SSE handoff frame (source → target persona, with bubbleColor) and an additive non-streaming secondMessage field let a single send render two persona-labeled bubbles. Each bot bubble is tinted to its persona's bubbleColor (per message, contrast-adjusted text); bubbleColor colors the message bubbles only, not the surface/theme, matching the web widget. No — additive/optional. Clients reading only reply (non-streaming) or only text frames (streaming) are unaffected; the extra bubble simply isn't rendered.
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.