npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

NPM version

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:

  1. Your backend mints a short-lived (1 hour), component-scoped embed token by calling POST /api/v1/components/:id/embed with your org API key (this call stays server-side).
  2. Your app fetches that token from your backend and hands it to the SDK.
  3. The SDK attaches the token to every request and calls the embed interact route. 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 API

The 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-sdk

The 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 only

Entry 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.

  1. Write getToken — one async function that takes a componentId and 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.
  2. Wrap your app (or just the screen that uses StudyFetch) in EmbedTokenProvider, plus ThemeProvider if you want your brand colors.
  3. Render a component with its componentId from your StudyFetch dashboard and the learner's userId.
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); // playable

Calling 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 build

example/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