@studyfetch/react-native-sdk
v0.3.1
Published
The official React Native SDK for StudyFetch — embed AI learning components (audio recaps, flashcards, tests, chat) natively in your mobile app
Downloads
188
Readme
StudyFetch React Native SDK
This library embeds StudyFetch AI learning components — audio recaps, flashcards, practice tests, chat, scenarios and performance insights — as native React Native UI in your mobile app.
The product documentation is on studyfetch.com. PARITY.md tracks which capabilities are implemented, which are planned, and which are deliberately out of scope for mobile.
For server-side use, see @studyfetch/sdk.
Contents
Getting started — Why this SDK · How auth works · Requirements · Install · Quick start
Components — All components · Audio Recap · Chat · Scenarios · Performance Insights · Headless hooks
Customizing — Theming · Optional adapters · Image attachments
Reference — Error handling · Token transport · Streaming · Offline
Project — Versioning · Contributing · License
Why this SDK
On the web, StudyFetch components render inside an <iframe>. On mobile that feels wrong — WebView scroll/keyboard behavior gives it away. This SDK renders each component as a native React Native UI that talks to the StudyFetch API directly, so it feels like part of your app.
How auth works (read this first)
Your mobile app never holds your organization API key — a full-org key inside a shipped binary can be extracted and abused. Instead:
- Your backend mints a short-lived (1 hour), component-scoped embed token by calling
POST /api/v1/components/:id/embedwith your org API key (this call stays server-side). - Your app fetches that token from your backend and hands it to the SDK.
- The SDK attaches the token to every request and calls the embed
interactroute. It refreshes shortly before expiry, and again if a request is rejected.
Embed tokens are scoped to one component, so getToken receives the component id and must mint for that component. The SDK caches one token per component and collapses concurrent refreshes into a single call to your backend.
Your backend (holds API key) ──mint──▶ embed token (1h, scoped)
│ │
└──────────────▶ Your app ◀──────────┘
│ token only
▼
@studyfetch/react-native-sdk ──▶ StudyFetch APIThe endpoint you have to build
One route on your backend, which authenticates your user and then mints. This is the part the SDK cannot do for you, and getting it wrong is the most common setup problem — so here it is in full:
// POST /studyfetch/token { componentId } → { token }
app.post('/studyfetch/token', async (req, res) => {
const user = await authenticate(req); // YOUR session, not StudyFetch's
if (!user) return res.status(401).end();
const minted = await fetch(
`https://studyfetchapi.com/api/v1/components/${req.body.componentId}/embed`,
{
method: 'POST',
headers: {
'x-api-key': process.env.STUDYFETCH_API_KEY, // stays server-side, always
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId: user.id, groupIds: user.cohorts }),
},
);
const { token } = await minted.json();
res.json({ token });
});Set userId from your authenticated session, never from the request body —
otherwise any caller can read another learner's progress and conversations by
asking for their id.
Performance Insights is the exception: its tokens are minted from
POST /api/v1/performance-insights/embed, which requires the learner's TrueLearn
userContextJwt. See Performance Insights.
Requirements
Peer dependencies:
| | Minimum |
| --- | --- |
| React Native | 0.74 |
| React | 18 |
| expo-audio (Audio Recap only) | 0.3.0 |
Works in Expo (managed or bare) and in plain React Native. Developed against React Native 0.79 / React 19.
Install
npm install @studyfetch/react-native-sdkThe core has no native dependency, which is what lets it install into any React Native or Expo app without a config plugin. Only Audio Recap needs anything extra:
npx expo install expo-audio # Audio Recap onlyEntry points
Each component is its own subpath, so an app that only uses Chat never loads the
audio code — and never pays for expo-audio being absent:
| Import from | You get | Extra dependency |
| --- | --- | --- |
| @studyfetch/react-native-sdk | EmbedTokenProvider, ThemeProvider, AdapterProvider, errors, transport | none |
| @studyfetch/react-native-sdk/chat | StudyFetchChat, useChat, useChatSessions | none |
| @studyfetch/react-native-sdk/audio-recap | StudyFetchAudioRecap, useAudioRecap | expo-audio |
| @studyfetch/react-native-sdk/flashcards | StudyFetchFlashcards, useFlashcards | none |
| @studyfetch/react-native-sdk/tests | StudyFetchTest, useTest | none |
| @studyfetch/react-native-sdk/scenarios | StudyFetchScenario, useScenario | none |
| @studyfetch/react-native-sdk/performance-insights | StudyFetchPerformanceInsights, usePerformanceInsights | none |
| @studyfetch/react-native-sdk/theme | ThemeProvider, useTheme, defaultTheme | none |
Quick start
Three steps: write the token resolver, wrap the tree in EmbedTokenProvider, drop
in a component.
- Write
getToken— one async function that takes acomponentIdand returns a token from the endpoint above. It is called lazily on first use and again near expiry, so it must mint for whichever component it is handed. - Wrap your app (or just the screen that uses StudyFetch) in
EmbedTokenProvider, plusThemeProviderif you want your brand colors. - Render a component with its
componentIdfrom your StudyFetch dashboard and the learner'suserId.
import { EmbedTokenProvider, ThemeProvider } from '@studyfetch/react-native-sdk';
import { StudyFetchChat } from '@studyfetch/react-native-sdk/chat';
async function fetchToken(componentId: string) {
// Call YOUR backend, which mints the token with your org API key.
const res = await fetch('https://your-backend.example.com/studyfetch/token', {
method: 'POST',
headers: { Authorization: `Bearer ${yourUserSession}` },
body: JSON.stringify({ componentId }),
});
const { token } = await res.json();
return token;
}
export default function App() {
return (
<EmbedTokenProvider getToken={fetchToken} onTokenExpired={fetchToken}>
<ThemeProvider theme={{ colors: { primary: '#0b2e63' } }}>
<StudyFetchChat componentId="chat_abc123" userId="user-123" materialIds={['mat_1']} />
</ThemeProvider>
</EmbedTokenProvider>
);
}Audio Recap
The one component with a host-app requirement: background playback needs a capability the SDK cannot declare for you.
// app.json / app.config.js
{ "ios": { "infoPlist": { "UIBackgroundModes": ["audio"] } } }Without it iOS suspends the audio session the moment the screen locks, and the recap stops mid-sentence when the listener puts the phone in their pocket — which is how most people listen. The SDK configures the rest of the session itself: background playback, playback in silent mode (the silent switch is usually left on, and without this a learner taps play, hears nothing, and assumes it's broken), and ducking rather than stopping when another app makes a sound.
import { StudyFetchAudioRecap } from '@studyfetch/react-native-sdk/audio-recap';
<StudyFetchAudioRecap componentId="audio_recap_x" userId="user-123" />What you get: play/pause, scrub, ±15s, playback speed (0.75x–2x with pitch correction), per-section progress that resumes where the listener stopped, auto-advance between sections, follow-up questions, and per-section feedback.
Pass a userId. Progress is per-user; without one the API accepts the write
and discards it, so resume silently never works. The SDK warns about this in dev.
Signed URLs
Audio lives behind GCS URLs signed for one hour. get_recap_by_component returns
whichever URL was signed when the audio was generated, so for any recap older
than an hour it is already dead. The SDK never plays that URL — it re-signs
through get_section before playback, caches the result, and re-signs again if a
URL lapses mid-listen. If you build your own player on useAudioRecap, use
resolveSectionUrl(sectionId) and never section.audioUrl:
const { sections, resolveSectionUrl } = useAudioRecap({ componentId, userId });
const url = await resolveSectionUrl(sections[0].id); // playableCalling it also makes the API start generating the next two sections, which is what keeps playback ahead of the listener.
Generating a recap
create() generates a new recap from the component's materials, or from a topic
alone. It resolves when generation finishes, not when it starts:
const { create } = useAudioRecap({ componentId, userId });
await create({
title: 'Cardiac pharmacology',
topic: 'beta blockers', // or rely on the component's materials
config: { duration: 10, numParts: 3, isMultiVoice: true, voice1: 'Morgan', voice2: 'Riley' },
});AudioRecapVoice is one of Alex, Morgan, Riley, Jordan, Sparky,
Taylor. voice2 applies only when isMultiVoice is true.
Components
Every component takes a componentId (from your StudyFetch dashboard) plus
optional userId and groupIds. Anything stored per learner needs userId —
see the Audio Recap note below for what happens without it.
| Component | Element | Hook | Streams |
| --- | --- | --- | --- |
| Audio Recap | StudyFetchAudioRecap | useAudioRecap | no |
| Flashcards | StudyFetchFlashcards | useFlashcards | no |
| Tests | StudyFetchTest | useTest | no |
| Chat | StudyFetchChat | useChat, useChatSessions | yes |
| Scenarios | StudyFetchScenario | useScenario | yes |
| Performance Insights | StudyFetchPerformanceInsights | usePerformanceInsights | yes |
import { StudyFetchAudioRecap } from '@studyfetch/react-native-sdk/audio-recap';
import { StudyFetchFlashcards } from '@studyfetch/react-native-sdk/flashcards';
import { StudyFetchTest } from '@studyfetch/react-native-sdk/tests';
import { StudyFetchChat } from '@studyfetch/react-native-sdk/chat';
import { StudyFetchScenario } from '@studyfetch/react-native-sdk/scenarios';
import { StudyFetchPerformanceInsights } from '@studyfetch/react-native-sdk/performance-insights';
<StudyFetchAudioRecap componentId="audio_recap_x" userId="user-123" />
<StudyFetchFlashcards componentId="flashcards_x" userId="user-123" mode="due" />
<StudyFetchTest componentId="test_x" userId="user-123" />
<StudyFetchChat componentId="chat_x" userId="user-123" materialIds={['mat_1']} />
<StudyFetchScenario componentId="scenario_x" userId="user-123" />
<StudyFetchPerformanceInsights componentId="pi_x" userId="user-123" timezone="America/New_York" />Contextual chat (the "SmartAssist" pattern)
Inject what the user is currently looking at into every chat message:
<StudyFetchChat
componentId="chat_x"
userId="user-123"
getContext={() => ({ embedContext: `The user is viewing question ${currentQ}.` })}
/>Chat
Chat streams by default, with the pieces the web embed has — citations, reasoning, follow-up chips, feedback, stop, and regenerate:
const {
messages, status, followUps,
sendMessage, stop, regenerate, sendFeedback,
} = useChat({ componentId: 'chat_x', userId: 'user-123', materialIds: ['mat_1'] });status is idle | submitted | streaming | error. Stopping keeps whatever text
already arrived and marks the message interrupted rather than discarding it.
Chat and Scenarios both accept stream={false} to take the whole answer in one
response instead, for networks where incremental delivery is unreliable:
<StudyFetchChat componentId="chat_x" stream={false} />Message text renders as plain selectable text by default. Pass renderText to
handle markdown, math, or links with whatever library you already use:
<StudyFetchChat componentId="chat_x" renderText={({ text }) => <Markdown>{text}</Markdown>} />On iOS, pass keyboardVerticalOffset (usually your nav-bar height) so the input
clears the keyboard. The default is 0 — a hardcoded guess sits wrong under most
host headers. Chat, Scenarios, and Performance Insights all take the same prop.
Past threads come from a separate hook, so an app that does not show history pays nothing for it:
const { sessions, getSession } = useChatSessions({ componentId: 'chat_x', userId: 'user-123' });Scenarios
A scenario is a role-play. The AI speaks as authored characters and drives the scene with real tool calls, which the SDK turns into attributed dialogue:
const {
scenario, messages, activeCharacterName,
submission, sendMessage, submit, restart,
} = useScenario({ componentId: 'scenario_x', userId: 'user-123' });When the AI switches character mid-answer, the line already spoken keeps its own
bubble under the previous speaker and a new one opens for the newcomer — a turn can
contain several speakers. Hints arrive in character, attached to the line they
accompany. When the AI ends the scenario, the graded evaluation arrives in the
tool's output (the backend rebuilds it from the authored rubric rather than
trusting the model's arguments) and submission is populated.
Learners can also finish deliberately: submit(finalAnswer) grades and completes
the session, while evaluate(draft) returns a preview grade without saving.
Performance Insights
The analytics assistant answers by running Python against the learner's data, so a single turn can carry narration, streamed reasoning, chart images, and tables:
const {
messages, sessions, conversationId,
ask, stop, openConversation, renameConversation, deleteConversation,
} = usePerformanceInsights({ componentId: 'pi_x', userId: 'user-123', timezone: 'America/New_York' });Charts are PNGs rendered server-side by matplotlib. The SDK displays them and offers
a full-screen viewer — there is no charting dependency, because the underlying series
never reaches the client. Pass debug to reveal the Python source and stdout behind
each answer.
Session expiry is two different things here. An expired embed token is refreshed
transparently like everywhere else. An expired TrueLearn userContextJwt is
terminal — StudyFetch does not issue that token, so no refresh can recover it. The SDK
surfaces it as TrueLearnSessionExpiredError and sets sessionExpired, and the
component renders a re-authentication prompt instead of a retry that would loop:
<StudyFetchPerformanceInsights componentId="pi_x" onReauthenticate={signInAgain} />Suggested prompts
Prompt chips for the empty state. The copy is yours; pass an array, or a function
to key it off suggestionContext:
<StudyFetchPerformanceInsights
componentId="pi_x"
userId="user-123"
suggestedPrompts={(ctx) => PROMPTS[ctx ?? ''] ?? DEFAULT_PROMPTS}
/>suggestionContext records where the learner opened the embed from — say
low_answer_confidence_correct_individual — and applies to one conversation:
| Conversation | suggestionContext |
| --- | --- |
| The one the learner lands on | the value from the embed token |
| newConversation() | none |
| Opened from history | whatever was stored on it |
Set it when your backend mints the token. Performance Insights tokens come from
its own route — POST /api/v1/performance-insights/embed — which takes
suggestionContext and requires the learner's TrueLearn userContextJwt:
body: JSON.stringify({
userContextJwt, // required
userId: user.id,
suggestionContext: 'answer_change_individual', // optional
})POST /api/v1/components/:id/embed does not accept suggestionContext —
sending it there is rejected as an unknown property.
Pass suggestionContext to the hook or component to override the token for one
screen. On the headless path, read the resolved value off the hook:
const { suggestionContext, messages, ask } = usePerformanceInsights({
componentId: 'pi_x',
userId: 'user-123',
});Prefer the function form of suggestedPrompts over calling
usePerformanceInsights in a parent to read the context — that is a second hook
instance for the same component, and only the first receives the token's value.
inspectEmbedToken and viewerRoleFromToken read the raw token claims.
Headless hooks
Prefer to build your own UI? Every component ships a hook:
import { useChat } from '@studyfetch/react-native-sdk/chat';
const { messages, sending, sendMessage } = useChat({ componentId: 'chat_x', userId: 'user-123' });useAudioRecap, useFlashcards, useTest, useScenario, and usePerformanceInsights follow the same pattern.
Theming
Components read from a theme you supply — pass a partial override to match your brand:
<ThemeProvider theme={{ colors: { primary: '#0b2e63', onPrimary: '#fff' }, radii: { md: 12 } }}>Error handling
The SDK throws typed errors (AuthenticationError, RateLimitError, EmbedTokenExpiredError, StreamError, …).
Token expiry is handled for you: a rejected token is re-minted and the request retried once. onTokenExpired lets you use a different resolver for that, but it is optional — getToken is the fallback, so a token that dies mid-request recovers either way.
Read-only requests are retried through rate limits and transient server errors with jittered backoff, honoring Retry-After. Writes (rating a card, submitting a test, sending feedback) are never replayed automatically, since replaying them would double the effect.
Token transport
The embed token is sent as the x-embed-token header by default. A token in a URL
ends up in access logs, proxy logs and crash-reporter breadcrumbs, and an embed
token is a bearer credential for one component — so that is a leak, not a hygiene
note.
If a header-authenticated request is rejected as expired or as
400 Token is required (the error from a build that only reads ?token=), the
provider retries the same token in the query string once and stays on query for
the rest of the session. Pin it to skip the probe:
<EmbedTokenProvider getToken={fetchToken} authMode="query">Note this applies to the interact route only. The other /embed/* routes read
the query string exclusively.
Streaming
Streaming responses need a transport that can read a response body incrementally.
React Native's built-in fetch cannot — it resolves only once the whole body has
arrived — so the SDK streams over XMLHttpRequest by default. That works on stock
React Native 0.74+ and Expo with no polyfill and no extra dependency.
If you would rather use a streaming fetch, pass one:
import { fetch as expoFetch } from 'expo/fetch';
<EmbedTokenProvider getToken={fetchToken} clientOptions={{ streamFetch: expoFetch }}>Hosts with unusual networking requirements can supply a streamAdapter instead and
own the transport entirely.
Reconnect
A stream that drops before producing any output is replayed automatically (two
attempts by default, with backoff). Tune it with maxStreamRetries.
Once a single token has reached the user, a drop is not replayed — the API has no resume protocol, so a replay would restate the answer from the beginning on top of the text already on screen. Those turns surface as interrupted, with the partial answer kept and a retry affordance.
Optional adapters
The core has no native dependency, which is what lets it install into any RN or Expo app without a config plugin. Anything that needs one is an adapter you register once:
import { AdapterProvider } from '@studyfetch/react-native-sdk';
import Markdown from 'react-native-markdown-display';
<AdapterProvider adapters={{ markdown: ({ text }) => <Markdown>{text}</Markdown> }}>
{/* chat, scenarios and performance insights all pick this up */}
</AdapterProvider>| Adapter | Without it |
| --- | --- |
| markdown | text renders plain and selectable; markdown syntax shows literally |
| compressImage | over-limit image attachments are rejected instead of downscaled |
Image attachments
A phone photo is 3–12MB, base64 inflates it by about a third, and it rides inside the JSON chat body — one unchecked image can push a request past 15MB. So there are limits: 5MB per image, 10MB per message, 5 images, configurable per hook.
<StudyFetchChat
componentId="chat_x"
attachmentLimits={{ maxAttachmentBytes: 2 * 1024 * 1024, maxAttachments: 3 }}
/>Over-limit attachments throw AttachmentTooLargeError before anything is
uploaded, and are never silently dropped — an image the model never received
still produces an answer, and that answer reads as a hallucination. Register a
compressImage adapter to downscale instead of rejecting.
Offline
The SDK caches nothing by default. AI features (chat answers, test grading, recap generation) always require a network connection to StudyFetch.
Offline queueing is deliberately not built. Holding a chat turn to send later
means replaying it against a conversation whose state has moved on, and the API
has no idempotency key that would make that safe — failing clearly beats silently
sending a question ten minutes after the user asked it. Read-only requests are
retried through transient failures with jittered backoff, honoring Retry-After;
writes (rating a card, submitting a test, sending feedback) are never replayed
automatically, since replaying them would double the effect.
Versioning
This package is 0.x. Breaking changes are called out in
CHANGELOG.md.
Everything re-exported from the package entry points is public API, including the streaming transport — it is exported so hosts can supply their own adapter, inspect raw protocol parts, or build a component this SDK does not ship yet.
Contributing
npm ci --legacy-peer-deps # the peer graph needs it: expo-audio ↔ expo ↔ react-dom
npm run typecheck
npm run lint
npm test
npm run buildexample/App.tsx exercises every component and both adapters under one provider.
Run it against your own component ids when changing component behavior: the test
suite drives hooks against a scripted stream adapter, so it cannot catch a
native-side regression.
Where this SDK and the other StudyFetch SDKs disagree about a payload, the API is the contract. PARITY.md lists the known divergences.
License
Apache-2.0
