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.
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
Install, mount the provider, send your first message.
iOS setup
Info.plist keys, mic permission, optional background audio.
Android setup
Manifest permissions, runtime mic prompt.
Voice
PCM16/24kHz, mute, barge-in, close-reason codes.
Quickstart
-
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
apiKeyvalue (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. -
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 -
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> ); } -
Apply the platform-specific config below. iOS needs an
NSMicrophoneUsageDescriptionstring; Android needs the autolinked permissions to merge in. Skip the iOS step if you don't plan to use voice yet. -
Run on a real device.
yarn iosoryarn android. The simulator works for text, but voice needs real hardware — simulator mic capture is unreliable at 24kHz PCM16.
Embed code
Paste this snippet before the closing </body> tag of your website.
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().
Mode
Pick how your platform identifies the visitor to the widget.
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 key | Holds | Cleared 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:
| Reason | What happened | Suggested UX |
|---|---|---|
user_ended | The visitor tapped End. | Dismiss sheet silently. |
permission_denied | Mic permission refused. | Show how to re-enable in Settings. |
cap_exhausted | Org hit its monthly voice-minute cap. | Tell the user to use text chat; surface to the operator that an upgrade is needed. |
silence_timeout | No audio in either direction for too long. | Friendly “Are you still there?” toast before closing. |
max_duration | Call hit the per-call cap (default 10 min). | Offer to continue in text. |
relay_error | Voice-relay WebSocket dropped unexpectedly. | Retry once; if it fails again, fall back to text. |
network_error | Device 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.
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:
-
POST /api/chatreturnsescalationPending: truein its response. -
The SDK opens a Server-Sent Events stream to
/api/widget/conversations/[id]/stream. - Agent messages, status changes, and takeover/handback events arrive on the stream and render in the chat view live — no polling, no refresh.
- If the user comes back to the app mid-takeover, the SDK reopens the stream automatically based on the restored conversation state.
- 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:
-
Streaming (
stream: true) — the/api/chatSSE stream emits framesdata: {type:"text"|"handoff"|"done", …}. Ahandoffframe —{type:"handoff", sourcePersonaName?, sourcePersonaId?, sourcePersonaBubbleColor?, sourcePersonaAvatarUrl?, personaName?, personaId?, personaBubbleColor?, personaAvatarUrl?}— finalizes the current bubble under the source persona (including itssourcePersonaAvatarUrl), then opens a fresh bubble for the target (with itspersonaAvatarUrl), whose intro streams in the followingtextframes. The closingdoneframe carries the target persona's metadata,personaAvatarUrlincluded. -
Non-streaming (
stream: false) — the JSON response gains an additivesecondMessage: {reply, personaName?, personaBubbleColor?, personaAvatarUrl?, personaId?}alongside the usualreply. The SDK rendersreplyas the source bubble andsecondMessage.replyas the target bubble.
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.
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:
| Method | Behavior |
|---|---|
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
| Field | Type | Required | Notes |
|---|---|---|---|
widgetKey | string | yes | cw_live_… from the dashboard. |
apiUrl | string | no | Defaults to https://chat.quincer.com/api. Override for self-hosted. |
pageUrl | string | no | Current page URL (full URL or path). Drives persona URL-pattern routing. |
personaId | string | no | Pin the conversation to a specific persona. |
stream | boolean | no | SSE 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
- v0.2 — Expo config plugin; agent handoff via Cloudflare Realtime SFU; attachment uploads; lead capture form.
- v0.3 — Push notifications for inbound messages (server-side webhook delivers FCM/APNs payload); offline message queue; standalone iOS Swift Package and Android Kotlin SDK (for teams not on React Native).
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.
| Date | Change | Affects 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. |