@lunora/react ships the official React bindings. It's a thin layer over
@lunora/client that maps the cache to useSyncExternalStore, so
concurrent React works correctly out of the box. The hooks live in the package
root (a client boundary); server-side data loading for React Server Components /
the Next.js App Router lives in @lunora/react/server.
import { LunoraClient } from "lunorash/client";
import { LunoraProvider, useMutation, useQuery } from "@lunora/react";
import { api } from "@/lunora/_generated/api";
const client = new LunoraClient({ url: import.meta.env.VITE_LUNORA_URL });
const Root = () => (
<LunoraProvider client={client}>
<Chat channelId="general" />
</LunoraProvider>
);<LunoraProvider client={...}>
Mounts the shared LunoraClient for the subtree. Required at the root.
useQuery(fn, args, options?)
Subscribes to a query. Returns T | undefined (undefined until the first
response). Pass "skip" for args to short-circuit (no network call).
Multiple useQuery calls with identical args share a single underlying
network call via the in-memory cache.
const messages = useQuery(api.messages.list, { channelId });
const profile = useQuery(api.users.me, signedIn ? {} : "skip");useMutation(fn)
Returns { mutate, pending, data, error, reset, withOptimisticUpdate }.
mutate is the awaitable call; pending is ref-counted across overlapping
calls from this hook, so it stays true until the last one settles. Destructure
what you need:
const { mutate, pending } = useMutation(api.messages.send);
await mutate(
{ channelId, text },
{
optimisticUpdate: (store, args) => {
const current = store.getQuery(api.messages.list, { channelId: args.channelId }) ?? [];
store.setQuery(api.messages.list, { channelId: args.channelId }, [...current, draft]);
},
},
);optimisticUpdate names the query it patches, which is what the send/list
shape needs: the per-call optimistic: (current) => next shortcut only patches a
subscription registered under the mutation's own reference and args, so it
fits a query and a mutation that share a path (a counter, a document by id) and
quietly does nothing for anything else.
To bind the same update to every call of a handle, use
withOptimisticUpdate(update), which returns the same handle with the update
applied by default.
Offline, mutate calls client.mutation at once when the client can queue the
write: the optimistic update paints, and the write goes to the offline queue (or
your durable outbox) to replay on reconnect. Before the shard's first connect
(unless queueBeforeFirstConnect is set), or on a client with no WebSocket, the
client cannot queue, so the call waits until the browser is back online and then
sends. TanStack never retries; the queue owns replays.
useAction(fn)
Returns { call, pending, data, error, reset }. call is the awaitable
invocation and rejects on failure; pending is ref-counted across overlapping
calls from this hook, so a sibling component calling the same action never
affects it.
data and error both track the latest invocation, not the last to settle:
a double-click whose first call resolves after the second cannot overwrite the
second's outcome. A success clears error; a failure leaves the previous data
in place, so a transient error does not blank the view. reset() clears both,
but does not cancel an in-flight call — its result still lands.
Unlike useMutation, the call never waits for the network, is never queued and
is never retried: client.action has no offline queue and sends no idempotency
key, so a paused call would fire minutes later on reconnect and a retry could run
a third-party side effect twice. It fails fast instead and lets you decide.
const { call: runVerify, pending } = useAction(api.commands.run);
await runVerify({ command: "lunora", args: ["verify"] });Per-call options are { shardKey } only — there is no optimistic /
optimisticUpdate and no withOptimisticUpdate. An optimistic update patches
the subscription cache on the assumption a write will land; an action is not a
write — it runs in the Worker, may call a third party, and has no declared
effect on any query. Offering the option would imply a rollback guarantee
nothing can honour.
useSubscription(fn, args)
Lower-level. Surfaces { data, error } so you can render error states
without a separate useState.
useAuth()
Returns { status, user, token, setToken(next) }. Call setToken(jwt) after a
sign-in flow; subsequent RPC calls carry the token automatically. It does not
re-authenticate an open subscription — the WebSocket credential is fixed at
upgrade time; client.setWsToken(token) rotates that.
status is the gate: "loading", "authenticated", "unauthenticated" or
"unreachable" (a credential is held but the identity endpoint could not be
reached — gates as authenticated). Branch on it, never on whether user is
null: user is null both when signed out and when a held credential's identity
has not resolved, so a user gate renders the sign-in form to a signed-in user.
See the @lunora/client reference for the full table.
const { setToken, status, user } = useAuth();
if (status === "loading") return <Spinner />;
if (status === "unauthenticated") return <SignInForm onToken={setToken} />;
return <Dashboard email={user?.email} />;Resolving the session also keys the client's offline-queue identity on the user id rather than the token bytes, so rotating the JWT for the same user keeps queued writes and the durable read cache instead of reading as a user switch. Between the rotation and the next session resolve, queued writes are held rather than replayed — the client cannot yet tell a refresh from an account switch.
Server Components (Next.js App Router)
Every hook in @lunora/react calls useState/useEffect and owns a live
WebSocket: they're client-only, and each module declares "use client".
Use them inside your own Client Components (the examples below mark
"use client" on the files that call them).
Server-side data loading lives in a separate, server-safe entry,
@lunora/react/server. It opens no socket and touches no browser globals,
so it's safe to call from a Server Component. There are two ways to render with
data already present on the first paint.
1. Prefetch + hydrate the TanStack cache
Run the query on the server, dehydrate the QueryClient, and wrap the client
subtree in HydrationBoundary. The client useQuery reads the value out of the
hydrated cache (same key, no loading flash) and a live subscription attaches on
mount.
import { createServerClient, prefetchQuery, dehydrate, HydrationBoundary } from "@lunora/react/server";
import { QueryClient } from "@tanstack/react-query";
import { cookies } from "next/headers";
import { api } from "@/lunora/_generated/api";
import { PostList } from "./post-list";
export default async function PostsPage() {
const client = createServerClient({
url: process.env.LUNORA_URL!,
token: (await cookies()).get("session")?.value,
});
const queryClient = new QueryClient();
await prefetchQuery(queryClient, client, api.posts.list, {});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}"use client";
import { useQuery } from "@lunora/react";
import { api } from "@/lunora/_generated/api";
export function PostList() {
// Reads the server-prefetched value from cache on the first render, then
// updates live as the WS subscription pushes changes.
const posts = useQuery(api.posts.list, {});
return (
<ul>
{posts?.map((p) => (
<li key={p._id}>{p.title}</li>
))}
</ul>
);
}Drop the await before prefetchQuery for fire-and-forget prefetch when you
don't need the data on the first paint.
2. Preload an explicit token
When you'd rather thread one resolved value to one component without a
HydrationBoundary, preloadQuery returns a serializable token you pass as a
prop. The client reads it with usePreloadedQuery.
import { createServerClient, preloadQuery } from "@lunora/react/server";
import { api } from "@/lunora/_generated/api";
import { Post } from "./post";
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const client = createServerClient({ url: process.env.LUNORA_URL! });
const preloaded = await preloadQuery(client, api.posts.get, { id });
return <Post preloaded={preloaded} />;
}"use client";
import { usePreloadedQuery } from "@lunora/react";
import type { Preloaded } from "@lunora/react/server";
export function Post({ preloaded }: { preloaded: Preloaded<{ title: string }> }) {
const post = usePreloadedQuery(preloaded); // server value first, then live
// `undefined` after a sign-out or user switch, until the new identity's subscription answers.
if (!post) {
return <p>Loading…</p>;
}
return <h1>{post.title}</h1>;
}usePreloadedQuery returns T | undefined. The preloaded value was read for whoever was
signed in when the page loaded, so after a sign-out or user switch it is dropped and the hook
returns undefined until the live value arrives: guard before reading fields.
Providers
Mount LunoraProvider once in a Client Component near the root. It creates (or
reuses) the TanStack QueryClient that HydrationBoundary hydrates into.
"use client";
import { LunoraClient } from "lunorash/client";
import { LunoraProvider } from "@lunora/react";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(() => new LunoraClient({ url: process.env.NEXT_PUBLIC_LUNORA_URL! }));
return <LunoraProvider client={client}>{children}</LunoraProvider>;
}@lunora/react/server reference
createServerClient({ url, token?, fetch? }): a request-scoped, HTTP-onlyLunoraClient. Build one per request so a user'stoken(and any cookies forwarded viafetch) never leak across requests.prefetchQuery(queryClient, client, fn, args, { shardKey? }?): runs the query and seedsqueryClientunder the key the client hooks use.preloadQuery(client, fn, args, { shardKey? }?): runs the query and returns a serializablePreloadedtoken forusePreloadedQuery.dehydrate/HydrationBoundary: re-exported from@tanstack/react-queryfor convenience.
File uploads (@lunora/react/upload)
End-user file uploads (live progress, pause/resume, large-file resumable and
per-part retry) are re-exported from @visulima/storage-client (Lunora does not
hand-roll the uploader). useUpload auto-selects TUS / chunked-REST / multipart
by file size; the TUS path survives a pause/resume and a dropped connection.
Point the endpoint at a route backed by
@lunora/storage/upload;
these uploads are gated by your per-user RLS policy, not the studio
adminToken.
TanStack Query reconciliation
The upload hooks (useUpload, useMultipartUpload, useTusUpload,
useChunkedRestUpload, useFileInput, usePasteUpload) hold their own state and
need no QueryClientProvider. The @visulima/storage-client data hooks
(useGetFileList …) do use TanStack Query, and LunoraProvider already mounts a
QueryClient, so they coexist with Lunora's own useQuery without extra wiring.
Drag-and-drop uploader with a progress bar
import { useCallback, useRef, useState } from "react";
import { useUpload } from "@lunora/react/upload";
export function Uploader() {
const [dragging, setDragging] = useState(false);
const controlRef = useRef(null);
// `endpointTus` points at the RLS-gated `@lunora/storage/upload` handler.
// The hook auto-selects TUS for large files (resumable), a plain multipart
// POST for small ones.
const { upload, progress, isUploading, isPaused, pause, resume, abort, error, result } = useUpload({
endpointTus: "/upload",
endpointMultipart: "/upload",
// Send the user's session so the server's `authorize` gate can identify them.
headers: async () => ({ authorization: `Bearer ${await getSessionToken()}` }),
restrictions: { maxFileSize: 5 * 1024 * 1024 * 1024, allowedFileTypes: ["video/*", "image/*"] },
onSuccess: (file) => console.log("uploaded", file.id),
});
const onDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
setDragging(false);
const file = event.dataTransfer.files[0];
if (file) void upload(file);
},
[upload],
);
return (
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
data-dragging={dragging}
className="dropzone"
>
{isUploading ? (
<>
<progress value={progress} max={100} />
<span>{progress}%</span>
{isPaused ? <button onClick={() => void resume?.()}>Resume</button> : <button onClick={() => pause?.()}>Pause</button>}
<button onClick={() => abort()}>Cancel</button>
</>
) : result ? (
<p>Uploaded ✓</p>
) : (
<p>{dragging ? "Drop to upload" : "Drag a file here"}</p>
)}
{error ? <p role="alert">{error.message}</p> : null}
</div>
);
}useUpload returns pause / resume / isPaused / offset only for the
resumable protocols (TUS / chunked-REST). For a plain <input type="file">,
useFileInput gives you the change-handler + selected-files plumbing; useTusUpload
is the lower-level hook when you always want the resumable protocol.
Admin auth hooks
Reactive reads over the admin-gated auth plane — the /_lunora/admin/auth/*
HTTP endpoints LunoraClient exposes as listAuthUsers, listAuthSessions,
listAuthOrganizations, and impersonateAuthUser. They are HTTP-only: unlike
useQuery there is no live subscription behind them (the auth-admin surface
does not ride the WS transport at all), so they are one-shot TanStack reads with
an explicit refetch(). The worker must be built with an authAdmin and an
adminToken, and the client must carry an admin-capable token.
| Hook | Returns |
|---|---|
useAuthUsers(options?) | AdminAuthListResult<AuthUser> |
useAuthSessions(options?) | AdminAuthListResult<AuthSession> |
useOrganizations(options?) | AdminAuthListResult<Record<string, unknown>> |
useImpersonate() | { data, error, pending, impersonate, reset } |
AdminAuthListResult<T> is
{ data, error, loading, hasMore, total, loadMore, refetch }: data is the
full current window (undefined before the first response), total is the
server-reported count across the whole collection, and loadMore() grows the
requested window by one page and re-fetches. It is a growing-window re-fetch,
not a cursor accumulator — the underlying AuthPage is offset/limit-based and
there is no live delta stream to keep page boundaries stable against.
Shared options: { enabled?, pageSize? } (pageSize defaults to 50).
useAuthUsers adds { search?, searchField?, filterField?, filterValue?, sortBy?, sortDirection? }; useAuthSessions adds { userId? } to scope the
list to one user.
AuthUser, AuthSession, AuthPage, and AuthImpersonation are re-exported
from the package root, so a console UI can annotate these results without
reaching into @lunora/client.
import { useAuthUsers, useImpersonate, type AuthUser } from "@lunora/react";
const UserTable = () => {
const { data, error, hasMore, loadMore, loading, refetch, total } = useAuthUsers({ pageSize: 25, search: "acme" });
const { impersonate, pending } = useImpersonate();
if (loading) return <p>Loading…</p>;
if (error) return <p role="alert">{error.message}</p>;
return (
<>
<p>
{data?.length ?? 0} of {total ?? 0}
</p>
<ul>
{(data ?? []).map((user: AuthUser) => (
<li key={user.id}>
{user.email}
<button disabled={pending} onClick={() => void impersonate(user.id)}>
Impersonate
</button>
</li>
))}
</ul>
{hasMore ? <button onClick={loadMore}>Load more</button> : null}
<button onClick={refetch}>Refresh</button>
</>
);
};Mutations stay plain client.* calls (client.createAuthOrganization,
client.deleteAuthOrganization, …); call the relevant hook's refetch()
afterwards — nothing is invalidated implicitly.
impersonate(userId) resolves an AuthImpersonation (bearer token + target
user + expiry) and deliberately does not call client.setAuthToken(...).
Swapping the current session would sign the admin out of their own admin
session with no visible transition and no way back; what to do with the token —
open it in a second tab, surface it for copy — is the caller's decision.
Agent tool events — useAgentToolEvents(options)
Observes one agent thread's tool activity, separate from its chat
transcript: which tools the model called, what they returned, which are parked
on a human approval, and any in-flight ctx.reportProgress(...) updates.
options: { api, threadKey, stream?, limit? } — api is the generated api
(it reads api.agents.agentMessages), and stream is the same app stream
reference useAgentChat takes. Returns { events }.
Each event discriminates on type:
type | Fields | Source |
|---|---|---|
call | toolCallId, toolName, input, seq | durable |
result | toolCallId?, toolName?, output, status?, seq | durable |
awaiting-approval | toolCallId?, toolName?, seq | durable |
progress | toolCallId, data | live stream |
Durable events come first, oldest first by seq, followed by the ephemeral
progress events for the in-flight turn. With no stream reference only the
durable lifecycle is surfaced. The array is rebuilt on every update — treat it
as derived, not identity-stable, and key rendered rows on toolCallId/seq.
import { useAgentToolEvents } from "@lunora/react";
import { api } from "@/lunora/_generated/api";
const ToolTimeline = ({ threadKey }: { threadKey: string }) => {
const { events } = useAgentToolEvents({ api, stream: api.chat.liveEvents, threadKey });
return (
<ol>
{events.map((event, index) => (
<li key={event.type === "progress" ? `p-${index}` : event.seq}>
{event.type === "call" ? `→ ${event.toolName}` : null}
{event.type === "result" ? `← ${event.output}` : null}
{event.type === "awaiting-approval" ? "⏸ awaiting approval" : null}
{event.type === "progress" ? JSON.stringify(event.data) : null}
</li>
))}
</ol>
);
};Voice agents — useVoiceAgent(options)
Opens a full-duplex voice call against a voice-enabled agent — the
api.agents.<name>Voice reference codegen emits. Microphone capture goes up the
agent's WebSocket, synthesized speech comes back down, and transcripts plus
barge-in are surfaced along the way.
options: { voice, threadKey, silenceThreshold?, silenceDurationMs?, interruptThreshold?, interruptChunks?, createMicrophone?, createSocket?, createSpeaker? }. threadKey is shared with the agent's text turns, so a voice
call continues the very same conversation useAgentChat renders.
Returns { status, connected, transcript, interimTranscript, audioLevel, isMuted, error, startCall, endCall, sendText, toggleMute }. status is
"idle" | "listening" | "thinking" | "speaking".
import { useVoiceAgent } from "@lunora/react";
import { api } from "@/lunora/_generated/api";
const CallButton = ({ threadKey }: { threadKey: string }) => {
const { audioLevel, endCall, startCall, status, transcript } = useVoiceAgent({
threadKey,
voice: api.agents.supportVoice,
});
return (
<>
<button onClick={status === "idle" ? () => void startCall() : endCall}>{status === "idle" ? "Call" : "Hang up"}</button>
<meter max={1} value={audioLevel} />
<p>{transcript}</p>
</>
);
};Microphone and audio lifecycle
The part the type signature does not tell you:
- Nothing opens until
startCall(). Creating the handle touches neither the microphone nor the socket.startCallis what callsgetUserMedia, so it has to run from a user gesture — browsers block both the permission prompt andAudioContextresumption outside one. It is idempotent while a call is active, and a denied permission lands inerrorrather than throwing at the call site. endCall()releases everything — the socket, the microphone tracks, and the Web Audio graph — and is idempotent. The hook also tears the call down on unmount, so a component that navigates away mid-call leaks nothing.audioLevelis the live input RMS (0–1). It drives a mic meter, and it is also what the heuristics read:silenceThreshold+silenceDurationMsdecide when an utterance auto-commits (defaults0.01/1200ms), andinterruptThreshold+interruptChunksdecide when the user barges in on the agent mid-sentence (defaults0.15/3consecutive chunks). These are room-dependent; tune them against real hardware rather than trusting the defaults.createMicrophone/createSpeaker/createSocketare injection seams for tests and non-DOM hosts. The defaults aregetUserMedia+ Web Audio +new WebSocket(url), so the primitive is inert (and mockable) anywhere those are missing.