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
Widget → Deploy and copy the
cw_live_…value. 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.
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 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.
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 | Logical “page” identifier for 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-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. |