npm install @playlive/react-query
TanStack Query hooks over @playlive/fundraiser-data — campaigns,
donations, milestones, leaderboards, donation trains, and the Play Live first-party
services, each wrapped in a useQuery / useInfiniteQuery / useMutation with
workspace-tuned defaults. The 19 hooks that also exist in
@playlive/react-data keep identical names, parameter shapes, and
return shapes, so those call sites swap tiers with a single import rewrite.
bun add @playlive/react-query @playlive/fundraiser-data @playlive/tiltify-core @tanstack/react-query react
All four are peer dependencies (jose-style — the consumer brings their own). None
are optional; package.json declares no peerDependenciesMeta:
| Peer | Range | Notes |
|---|---|---|
react |
^19.0.0 |
Hooks only — no react-dom, nothing here renders. |
@tanstack/react-query |
^5.0.0 |
v5 API (isPending, initialPageParam, object-form useQuery). v4 will not work. |
@playlive/fundraiser-data |
workspace:* |
Every fetcher this package wraps. Must be configure()d at boot. |
@playlive/tiltify-core |
workspace:* |
Type-only at runtime — the hooks module imports it under import type, so nothing of it ships in the emitted bundle. Still a required peer because the emitted .d.ts references TiltifyCampaign, TiltifyDonation, etc. |
dependencies is empty. sideEffects: false, so unused hooks tree-shake out.
Three steps: configure() the data layer, build a QueryClient, mount the provider.
import { QueryClientProvider } from "@tanstack/react-query";
import { configure } from "@playlive/fundraiser-data";
import { makeQueryClient, useCampaign, useCampaignAmounts } from "@playlive/react-query";
// 1. Point the data layer at the Tiltify proxy (once, at module scope).
configure({ tiltifyProxyUrl: "https://api.experience.stjude.org/tiltify" });
// 2. A QueryClient pre-seeded with the workspace defaults
// (5.1 s staleTime, 5 s polling, 10 retries — see the table below).
const qc = makeQueryClient();
// 3. Provide it above every component that calls a hook.
export function App() {
return (
<QueryClientProvider client={qc}>
<ProgressBar campaignId="demo-campaign-a" />
</QueryClientProvider>
);
}
function ProgressBar({ campaignId }: { campaignId: string }) {
const campaign = useCampaign({ charityType: "tiltify", id: campaignId });
const { totalAmount, goalAmount } = useCampaignAmounts(campaign.data);
if (campaign.isPending) return <p>Loading…</p>;
if (campaign.error) return <p>Error: {campaign.error.message}</p>;
if (!campaign.data) return <p>No campaign.</p>;
const percent = goalAmount > 0 ? (totalAmount / goalAmount) * 100 : 0;
return (
<figure>
<figcaption>{campaign.data.name}</figcaption>
<progress value={totalAmount} max={goalAmount} />
<span>{percent.toFixed(1)}%</span>
</figure>
);
}
configure() is re-exported from both the @playlive/fundraiser-data barrel and its
/config subpath. tiltifyProxyUrl is the only required field; twitchServiceUrl,
causeId, scheduleApiUrl, lifetimeApiUrl, leaderboardApiUrl,
donorSpotlightApiUrl, and donationTrainApiUrl are optional and gate the hooks that
talk to those services.
isPending vs isLoadingBoth are passed straight through from TanStack Query v5, and they differ in exactly one
case that matters here: a hook whose id is nullish is disabled, which leaves
isPending: true forever while isLoading is false. Branch on isPending when you
want "we have neither data nor an error yet"; branch on isLoading when you want "a
first fetch is actually in flight".
@playlive/react-data@playlive/react-data implements its hooks on a single useFetch primitive built from
useState / useRef — the state lives inside the calling component. Two components
asking for the same campaign issue two independent requests and hold two copies. This
package hands the same work to a QueryClient, which buys:
queryKey share one cache
entry and one in-flight request. useCampaigns rows de-dupe against sibling
useCampaign consumers for free.useInfiniteDonations / useTiltifyLeaderboard sit on
useInfiniteQuery; the dependency-free tier has no equivalent.useLeaderboardExclusions invalidates its own read
on a successful add/remove, so the list updates without waiting for a poll.@tanstack/react-query-devtools can inspect them. This package does not bundle or
re-export devtools; add them yourself if you want them.The cost is the @tanstack/react-query peer in your bundle. If you only need the 19
shared hooks and want the smaller graph, use @playlive/react-data.
| Subpath | Contents |
|---|---|
@playlive/react-query |
Default barrel — re-exports /config, every hook, /types, plus PACKAGE_NAME and KNOWN_URLS. |
@playlive/react-query/config |
DEFAULT_QUERY_OPTIONS + makeQueryClient. |
@playlive/react-query/types |
UseFetchOptions, UseFetchResult, UseInfiniteDonationsResult, HookRefetchOptions. |
That is the complete exports map — there is no /hooks subpath; import hooks from the
barrel. Each entry ships an ESM bundle, a bun source condition pointing at src/, and
.d.ts declarations.
UseFetchResult<T>Every row takes (params, options?: UseFetchOptions) and returns
UseFetchResult<T> ({ data, error, isLoading, isPending, isFetching, refetch }).
"Auto-disabled" lists the guard that flips enabled to false.
| Hook | data |
Auto-disabled when |
|---|---|---|
useCampaign |
TiltifyCampaign | TiltifyPersonalCampaign | TiltifyTeamCampaign | null |
never |
useFlattenedDonations |
TiltifyDonation[] |
campaignId nullish |
useMilestones |
TiltifyMilestone[] |
campaignId nullish |
useRewards |
TiltifyReward[] |
campaignId nullish |
usePolls |
TiltifyPoll[] |
campaignId nullish |
useTargets |
TiltifyTarget[] |
campaignId nullish |
useSchedule |
TiltifySchedule[] |
campaignId nullish |
useUser |
TiltifyUser | null |
userSlug empty |
useTeam |
TiltifyTeam | null |
teamSlug empty |
useFundraisingEvent |
TiltifyFundraisingEvent | null |
eventId nullish |
useFundraisingEventMilestones |
TiltifyFactMilestone[] |
eventId nullish |
useCause |
TiltifyCause | null |
causeId nullish |
useEventCampaigns |
TiltifyCampaign[] |
eventId nullish |
useCurrentEvents |
TiltifyFundraisingEvent[] |
never |
useTiltifyUserCampaigns |
TiltifyPersonalCampaign[] |
userId nullish, empty, or the string "null" |
useTiltifyUserAndTeamCampaigns |
(TiltifyPersonalCampaign | TiltifyTeamCampaign)[] |
userId nullish, empty, or the string "null" |
useScheduleBlockRaised |
ScheduleBlockRaised | undefined |
any of campaignId / start / end nullish |
useLifetimeRaised |
number | null |
username nullish or empty |
usePreviousYearTotals |
PreviousYearTotalItem[] |
slug nullish or empty |
useGiftsThatGiveMilestones |
GiftsThatGiveMilestone[] |
goal nullish |
useDonorSpotlightOverview |
DonorSpotlightSnapshot | null |
campaignId nullish or empty |
useLeaderboardWithExclusions |
MonetaryLeaderboardEntry[] |
campaignID nullish or empty |
useDonationTrains |
DonationTrain[] |
campaignID nullish or empty |
useDonationTrainHighRateDonors |
DonationTrainHighRateDonor[] |
campaignID nullish or empty |
useCampaignRulesets |
DonationTrainRuleset[] |
campaignID nullish or empty |
useDonationTrainCommonTrains |
CommonDonationTrain[] |
campaignID nullish or empty |
useRewards and useTargets additionally accept sort?: boolean in params.
useFundraisingEventMilestones takes an optional charityType that defaults to
"tiltify". The donation-train and leaderboard-exclusion hooks take
(params, authAndOptions?) where the second argument is
DonationTrainAuthOptions & UseFetchOptions (or LeaderboardAuthOptions & UseFetchOptions) —
adminApiKey / tiltifyOAuthToken are split out and never reach TanStack.
Twitch-unsupported entities (useMilestones, useRewards, usePolls, useTargets,
useSchedule, useUser, useTeam, useFundraisingEvent, useFundraisingEventMilestones,
useCause, useEventCampaigns, useLeaderboardWithExclusions) resolve to [] / null
on the charityType: "twitch" path rather than throwing — same lenient semantics as the
underlying fetchers.
Per-hook cadence overrides. Some hooks override DEFAULT_QUERY_OPTIONS because their
upstream data doesn't change every five seconds. Pass options.refetchInterval /
options.staleTime to win back control:
| Hook | staleTime |
refetchInterval |
|---|---|---|
useTiltifyUserCampaigns, useTiltifyUserAndTeamCampaigns |
Infinity |
false |
useScheduleBlockRaised (also refetchOnReconnect: false) |
Infinity |
false |
useLifetimeRaised |
5_100 |
60_000 |
usePreviousYearTotals |
5_100 |
5 * 60_000 |
useDonationTrains, useCampaignRulesets |
5_100 / 5_000 |
15_000 |
useDonationTrainHighRateDonors, useDonationTrainCommonTrains |
5_100 |
25_000 |
| Hook | Returns |
|---|---|
useCampaigns |
UseCampaignsResult — { data[], errors[], isLoading, isPending, isFetching, isError, refetch }, ordered 1-for-1 with the params array. Built on useQueries + combine. Rows are never auto-disabled; a row with no id still fires. |
useInfiniteDonations |
UseInfiniteDonationsResult<PaginatedResponse<TiltifyDonation, TiltifyPaginationMetadata | TwitchPaginationMetadata>> |
useTiltifyLeaderboard |
UseTiltifyLeaderboardResult — { entries, data, error, isLoading, isPending, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage, refetch }. Forward-only; no fetchPreviousPage. |
useLeaderboardExclusions |
UseLeaderboardExclusionsResult — { data, donorNames, error, isLoading, isPending, isFetching, isMutating, addExclusion, removeExclusion, refetch } |
useLeaderboard |
UseLeaderboardResult — { leaderboard, donations, currentTotal, exclusions, isLoadingExclusions, isLoadingDonations, isFetching, isFetchingNextPage, hasNextPage, error }. Note: no isLoading, no refetch. |
useCampaignGiftsThatGiveMilestones |
{ giftMilestones: GiftsThatGiveMilestoneItem[]; isLoading: boolean } |
useDonationTrainState |
UseDonationTrainStateResult — { trains, updateTrainStatus, updateTrainVisibility, highRateDonors, commonTrains, isLoading } |
useCampaignRulesetsState |
UseCampaignRulesetsStateResult — { rulesets, updateRuleset, deleteRuleset, createRuleset } |
These issue no requests — they're useMemo / useState / setTimeout helpers that
happen to live here so overlays can import one package.
| Hook | Signature |
|---|---|
useCampaignAmounts |
(campaignOrFundraiser: CampaignLike | null | undefined) => CampaignAmounts — reference-stable { totalAmount, currentAmount, goalAmount, originalGoalAmount, supportingAmount }. |
useCampaignFundraisingEventAmounts |
(campaign, fundraisingEvent, options?: { forceCampaignGoal?: boolean }) => CampaignFundraisingEventAmounts |
useDonationsReducer |
(donationsData: DonationPagesInput | null | undefined, flipSorting = false, onlyReturnNew = false) => TiltifyDonation[] — flattens a { pages, pageParams } envelope; sticky-accumulates by default so rows survive maxPages trimming. |
useAlertsQueue |
(params: UseAlertsQueueParams, options?: UseAlertsQueueOptions) => { donation: TiltifyDonation | null } — display-timing loop over an external donation queue. |
Each returns UseMutationResult<TData, TVariables> —
{ mutate, mutateAsync, data, error, isPending, reset }, a projection of TanStack's
useMutation so consumers don't need TanStack's types.
| Hook | Argument | TVariables |
TData |
|---|---|---|---|
useUpdateTrainVisibility |
auth? |
{ trainID: string; trainVisible: boolean } |
DonationTrain | null |
useRefreshTrainStatus |
— | { trainID: string } |
DonationTrain | null |
useProcessDonationsForTrains |
— | { donations: TiltifyDonation[] } |
DonationTrain[] |
useCreateCampaignRuleset |
auth? |
{ campaignID: string; ruleset: PartialDonationTrainRuleset } |
DonationTrainRuleset | null |
useUpdateRuleset |
auth? |
{ ruleset: DonationTrainRuleset; campaignID?: string } |
DonationTrainRuleset |
useDeleteRuleset |
auth? |
{ rulesetID: string; campaignID?: string } |
{ id: string } | null |
useTestDonations |
auth? |
TiltifyDonation | TiltifyDonation[] |
void |
auth is DonationTrainAuthOptions (TestDonationAuthOptions for useTestDonations) —
pass adminApiKey or tiltifyOAuthToken. useUpdateRuleset / useDeleteRuleset
pick their route from the presence of campaignID: with it, the campaign-scoped route
(admin key or OAuth); without it, the admin-only legacy route.
useTestDonations fires a synthetic donation through the core REST API
(POST /donations/tiltify/test), which replays it down the same webhook path a real
donation takes — alerts, donation trains, the subathon timer, and every WebSocket
subscriber react as if Tiltify had delivered it. Demo campaigns skip auth entirely.
| Export | Kind | Value |
|---|---|---|
PACKAGE_NAME |
const |
"@playlive/react-query" — for runtime version-pinning checks. |
KNOWN_URLS |
const |
readonly string[], frozen and empty. See the URL-disclosure section. |
DEFAULT_QUERY_OPTIONS |
const |
Workspace query defaults (table below). Also on /config. |
DEFAULT_ALERT_DURATIONS_MS |
const |
Readonly<Record<DonorLevel, number>> — platinum 9500, gold 8500, silver 7500, bronze 6500, grey 5500. Merged under useAlertsQueue's durations override. |
| Export | Kind | Signature |
|---|---|---|
makeQueryClient |
function |
(overrides?: QueryClientConfig) => QueryClient — builds a client whose defaultOptions.queries is DEFAULT_QUERY_OPTIONS with overrides.defaultOptions.queries merged on top. |
UseFetchOptions)| Option | Default | Description |
|---|---|---|
enabled |
true |
Skip fetching when false. ANDed with the hook's own nullish guard. |
refetchInterval |
5_000 |
Poll every N ms. Pass false to disable polling. |
retry |
10 |
Retries on error. Pass false (or 0) in tests, or for hooks whose id may legitimately 404. |
retryDelay |
1_000 |
Base delay (ms); TanStack applies exponential backoff on top. |
staleTime |
5_100 |
Freshness window. Pass 0 to make every read refetch. |
initialData |
— | Seed the query (TanStack initialData) so a route loader's payload paints immediately. Typed unknown; cast at the call site. For useCampaigns an array seeds rows 1:1 against params, a scalar seeds every row. |
maxPages |
— | Retained-page cap. Honoured only by useInfiniteDonations and useTiltifyLeaderboard; every other hook ignores it. TanStack v5 drops the oldest page at the limit. |
cachingEnabled |
— | Boolean cache partition. Contributes to the queryKey of useInfiniteDonations / useTiltifyLeaderboard only — the fetcher is unchanged. |
initialData, maxPages, and cachingEnabled are stripped from the merged options bag
before it reaches TanStack and re-applied per hook with a narrow cast; without that,
initialData?: unknown would collapse TQueryFnData inference to unknown everywhere.
DEFAULT_QUERY_OPTIONS)Every hook merges these in as base defaults (caller options wins), and
makeQueryClient() installs the same object as the client's defaultOptions.queries.
| Option | Value | Rationale |
|---|---|---|
staleTime |
5_100 |
Matches overlay-data-layer's existing freshness window. |
refetchInterval |
5_000 |
Sane polling for live overlays. |
retry |
10 |
Flaky stream-conf networks; backoff protects the proxy. |
retryDelay |
1_000 |
Base for TanStack's exponential backoff schedule. |
refetchOnWindowFocus |
false |
OBS browser sources have no meaningful focus events. |
refetchOnReconnect |
true |
Recovery after a network blip is the right live semantic. |
Exported from the barrel and from @playlive/react-query/types:
interface UseFetchResult<T> {
data: T | undefined;
error: Error | null;
isLoading: boolean;
isPending: boolean;
isFetching: boolean;
refetch: (options?: HookRefetchOptions) => Promise<void>;
}
interface UseInfiniteDonationsResult<TPage> {
data: { pages: TPage[]; pageParams: Array<string | number | null | undefined> } | undefined;
error: Error | null;
isLoading: boolean;
isPending: boolean;
isFetching: boolean;
isFetchingNextPage: boolean;
isFetchingPreviousPage: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
fetchNextPage: () => Promise<void>;
fetchPreviousPage: () => Promise<void>;
refetch: (options?: HookRefetchOptions) => Promise<void>;
}
/** Structural subset of TanStack v5's `RefetchOptions`. */
interface HookRefetchOptions {
cancelRefetch?: boolean; // default true — cancels an in-flight fetch first
}
refetch is wrapped in a useCallback keyed on TanStack's own refetch, so its
identity is stable across renders — safe to list in a useEffect dep array without
looping.
Every hook also exports its Use…Params / Use…Result interface (UseCampaignParams,
UseInfiniteDonationsParams, UseLeaderboardParams, LeaderboardRow,
GiftsThatGiveMilestoneItem, UseMutationResult, …) from the barrel. Domain payload
types (TiltifyDonation, DonationTrain, MonetaryLeaderboardEntry,
ScheduleBlockRaised, …) are not re-exported — import them from
@playlive/tiltify-core and @playlive/fundraiser-data.
Full generated API documentation: https://packages.playlive.experience.stjude.org/p/@playlive/react-query/docs/
No external API surface of its own. Every hook wraps a fetcher from
@playlive/fundraiser-data, so the endpoints, authentication,
and URL knobs are entirely that package's — configure() it once at boot and these
hooks inherit whatever you pointed it at (the Tiltify proxy, the Twitch charity
service, and the Play Live schedule / lifetime-raised / leaderboard / donor-spotlight /
donation-train services). See that package's README for the full
list, and Tiltify's public developer documentation at https://developers.tiltify.com
for the upstream resource shapes.
The KNOWN_URLS export enumerates every absolute URL or host this package can fetch.
It is empty — this package hardcodes no production hosts; every request goes through
a @playlive/fundraiser-data fetcher pointed at a URL you supplied to configure().
import { KNOWN_URLS } from "@playlive/react-query";
console.log(KNOWN_URLS); // []
For an Extension submission, disclose @playlive/fundraiser-data's URLs plus whatever
you passed for tiltifyProxyUrl, twitchServiceUrl, and the Play Live service URLs.
import { QueryClientProvider } from "@tanstack/react-query";
import { configure } from "@playlive/fundraiser-data";
import {
makeQueryClient,
useCampaign,
useCampaignAmounts,
useMilestones,
useLeaderboard,
} from "@playlive/react-query";
configure({
tiltifyProxyUrl: "https://api.experience.stjude.org/tiltify",
leaderboardApiUrl: "https://api.experience.stjude.org/leaderboard",
});
const queryClient = makeQueryClient();
export function OverlayRoot({ campaignId }: { campaignId: string }) {
return (
<QueryClientProvider client={queryClient}>
<Overlay campaignId={campaignId} />
</QueryClientProvider>
);
}
function Overlay({ campaignId }: { campaignId: string }) {
const campaign = useCampaign({ charityType: "tiltify", id: campaignId });
const milestones = useMilestones({ charityType: "tiltify", campaignId });
// Composes useInfiniteDonations + useLeaderboardExclusions; `eagerFetchPages`
// (default true) walks the cursor so the ranking sees every donation.
const { leaderboard, currentTotal, isLoadingDonations } = useLeaderboard({
charityType: "tiltify",
campaignId,
limit: 5,
removeAnonymous: true,
});
const { totalAmount, goalAmount } = useCampaignAmounts(campaign.data);
if (campaign.isPending) return <p>Connecting…</p>;
if (campaign.error) return <p role="alert">Campaign failed: {campaign.error.message}</p>;
if (!campaign.data) return <p>Campaign not found.</p>;
return (
<section>
<h1>{campaign.data.name}</h1>
<progress value={totalAmount} max={goalAmount} />
<ul>
{(milestones.data ?? [])
.filter((m) => m.active)
.map((m) => (
<li key={m.id}>
{m.name} — ${m.amount.value}
</li>
))}
</ul>
<h2>Top donors (${currentTotal.toFixed(2)} raised)</h2>
{isLoadingDonations ? (
<p>Tallying…</p>
) : (
<ol>
{leaderboard.map((row) => (
<li key={row.id}>
{row.donor_name} — ${row.amount.toFixed(2)}
</li>
))}
</ol>
)}
</section>
);
}
cause_id lives on every campaign shape, so the cause lookup can chain off the campaign
result. Passing undefined for causeId keeps useCause disabled (and therefore
isPending) until the first campaign resolves. The cause record is static reference
data, so polling is switched off for it.
import { useCampaign, useCause } from "@playlive/react-query";
function CauseBadge({ campaignId }: { campaignId: string }) {
const campaign = useCampaign({ charityType: "tiltify", id: campaignId });
const cause = useCause(
{ charityType: "tiltify", causeId: campaign.data?.cause_id },
{ refetchInterval: false, staleTime: Number.POSITIVE_INFINITY },
);
if (!cause.data) return null;
return <img src={cause.data.avatar?.src} alt={cause.data.name} />;
}
useInfiniteDonations is the tier-exclusive cursor hook. Tiltify pages on an opaque
string cursor (metadata.after), Twitch on a numeric page index (metadata.nextPage);
getNextPageParam reads both, so charityType can be a runtime value. Only Tiltify
exposes a reverse cursor (metadata.before), so hasPreviousPage is permanently false
on the Twitch path and fetchPreviousPage no-ops there.
useDonationsReducer flattens the { pages, pageParams } envelope into a sorted array
and — with onlyReturnNew left at false — keeps rows that maxPages has already
trimmed out of the cache, which is what a scrolling column wants.
import { useEffect } from "react";
import { useDonationsReducer, useInfiniteDonations } from "@playlive/react-query";
function DonationFeed({ campaignId }: { campaignId: string }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
error,
} = useInfiniteDonations(
{ charityType: "tiltify", campaignId, count: 50 },
{ maxPages: 5 },
);
const donations = useDonationsReducer(data);
// Walk the cursor to the end once, then let the 5 s poll pick up new rows.
useEffect(() => {
if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isPending) return <p>Loading donations…</p>;
if (error) return <p role="alert">{error.message}</p>;
return (
<ul>
{donations.map((d) => (
<li key={d.id}>
{d.donor_name || "Anonymous"} — ${d.amount.value}
</li>
))}
</ul>
);
}
The read (GET /leaderboard-exclusions/{id}) is public; the POST / DELETE behind
addExclusion / removeExclusion need adminApiKey (sent as x-api-key) or
tiltifyOAuthToken (sent as Authorization: OAuth <token>). Auth keys are split out of
the second argument before the rest forwards to TanStack, and a successful mutation
invalidates the read so the list refreshes without waiting for a poll.
import { useLeaderboardExclusions } from "@playlive/react-query";
function ExclusionEditor({ campaignID, token }: { campaignID: string; token: string }) {
const { donorNames, isMutating, addExclusion, removeExclusion } = useLeaderboardExclusions(
{ campaignID },
{ tiltifyOAuthToken: token, refetchInterval: 30_000 },
);
return (
<ul>
{donorNames.map((name) => (
<li key={name}>
{name}
<button type="button" disabled={isMutating} onClick={() => void removeExclusion(name)}>
Remove
</button>
</li>
))}
<li>
<button type="button" disabled={isMutating} onClick={() => void addExclusion("Bob")}>
Exclude Bob
</button>
</li>
</ul>
);
}
The 19 hooks below exist in both packages with identical params and identical
UseFetchResult returns, so migrating those call sites is one import rewrite:
-import { useCampaign, useMilestones } from "@playlive/react-query";
+import { useCampaign, useMilestones } from "@playlive/react-data";
useCampaign, useCampaigns, useCause, useEventCampaigns, useFlattenedDonations,
useFundraisingEvent, useGiftsThatGiveMilestones, useLifetimeRaised, useMilestones,
usePolls, usePreviousYearTotals, useRewards, useSchedule, useScheduleBlockRaised,
useTargets, useTeam, useTiltifyUserAndTeamCampaigns, useTiltifyUserCampaigns, useUser.
Drop <QueryClientProvider> if nothing else in the tree needs it. Everything outside
that list — infinite pagination, leaderboards, donation trains, mutations, alerts, and
the Play Live first-party services — is react-query-only; those call sites have to stay
on this tier. See @playlive/react-pipeline for the fusion hooks
that layer live WebSocket updates over these REST baselines.
MIT © St. Jude Children's Research Hospital