@vxil/react
v0.2.3
Published
The Vxil React component library — drop-in, themeable widgets over the stable Vxil APIs, one package with five subpath entries: @vxil/react/copilot (<CopilotProvider>/<CopilotThread>/<Composer>/<ProposalCard> + useCopilot, propose→confirm AI actions), @vx
Maintainers
Readme
@vxil/react
The Vxil React component library — drop-in, themeable widgets over the stable Vxil REST + realtime APIs, in one package with five subpath entries (each tree-shakes independently; importing one pulls in nothing from the others):
| Subpath | What you get |
| --- | --- |
| @vxil/react/copilot | An in-app AI assistant — <CopilotProvider> / <CopilotThread> / <Composer> / <ProposalCard> + useCopilot(), with propose→confirm write actions. |
| @vxil/react/feed | Activity streams + in-app notifications — <FeedProvider> / <FeedTimeline> / <NotificationBell> + useActivityFeed() / useNotifications(), live. |
| @vxil/react/comments | Threaded comments + file uploads — <CommentThread> + useComments(), and <UploadWidget> / <Dropzone> + useUpload(). |
| @vxil/react/entitlements | Feature gating over the payments integration — useEntitlement() (a three-state answer that never downgrades on a failed read) + <Paywall> / <ManageSubscription>. |
| @vxil/react/auth | Passwordless sign-in state machines — useOtpFlow() / useMagicLink() / useGuestSession() + <OtpFlow>, with injected token storage, refresh-before-expiry, and sign-out. |
npm install @vxil/react @vxil/sdk
# react / react-dom are peers you already have in your app (React 18 or 19)react / react-dom are peer dependencies (>=18; works on React 19).
@vxil/sdk is an optional peer — the components take a client port (a
structural slice of the SDK surface), so you'll normally construct a Vxil
client and pass its namespaces in, but nothing here hard-requires the SDK at
runtime. The /feed subpath brings @vxil/realtime (the reconnecting
WebSocket client) with it for live updates.
This is an npm package for a bundled app (Vite/Next/etc.) — install it and
import from the subpaths. Unlike the base @vxil/sdk / @vxil/realtime,
there is no env-baked served .mjs build of the React components; they
ship only via npm.
Shared design, all five families:
- Pure, node-testable cores + thin React bindings. All state + logic lives
in framework-free stores/state machines (
CopilotStore,FeedStore,NotificationStore,CommentStore,UploadMachine,EntitlementStore,SessionManager,OtpFlowStore,MagicLinkFlow,GuestSession) — exhaustively unit-tested; the React layer is a thin projection viauseSyncExternalStore. The cores are exported too, so you can drive everything without React. - XSS floor. Model and user content is always rendered as text nodes —
never
dangerouslySetInnerHTML. - Bring your own CSS. Minimal/class-based styling with stable
data-vxil-*/ class hooks, unopinionated by design.
@vxil/react/copilot
React components + hooks for vxil copilot agents — the CopilotKit / assistant-ui parity surface, built entirely on the stable copilot REST + resume-stream API. It complements the hosted iframe widget with a first-class, themeable, in-app assistant.
- Grounded, cited answers. Every turn retrieves, grounds, and generates a cited answer; citations are surfaced inline.
- Propose → confirm write actions. A WRITE action the agent wants to take is
proposed, never auto-run.
<ProposalCard>shows the tool + args and a Confirm button — only then does the real internal route execute (with the caller's own scopes; nothing widens). - Streaming. Answers stream in token-by-token via the durable resume buffer
(
GET …/stream?since=N) — no socket required, and a dropped connection replays without duplicating text.
import { Vxil } from '@vxil/sdk';
import { CopilotProvider, CopilotThread, Composer } from '@vxil/react/copilot';
// In a thin-client app, scope the client to the signed-in end user; for a
// guest widget, mint a guest bearer and use it as the end-user token.
const vx = new Vxil({ apiKey: THIN_CLIENT_KEY, endUserToken: userSessionToken });
export function Assistant() {
return (
<CopilotProvider client={vx.copilot} agentId="support" userId={currentUserId}>
<CopilotThread />
<Composer placeholder="Ask the assistant…" />
</CopilotProvider>
);
}The hook
import { useCopilot } from '@vxil/react/copilot';
function Toolbar() {
const { messages, sending, usage, send, confirm } = useCopilot();
return (
<div>
<span>{usage.totalTokens} tokens · {usage.turns} turns</span>
<button disabled={sending} onClick={() => send('Summarize this thread')}>
Summarize
</button>
</div>
);
}Proposal (write) actions
When an agent proposes a write, <CopilotThread> renders an inline
<ProposalCard> automatically. Nothing runs until the user presses Confirm;
Decline is a purely local no-op (a proposal is inert on the server too). Pass
an idempotency key to make a retried confirm a downstream no-op:
<ProposalCard message={m} idempotencyKey={m.id} />Components
| Export | What it is |
| ------------------ | ---------------------------------------------------------------------- |
| <CopilotProvider>| Context: the client + agent id (+ optional userId, conversationId). |
| useCopilot() | { messages, sending, usage, send, confirm, reject, … }. |
| <CopilotThread> | The transcript — bubbles, citation counts, inline <ProposalCard>. |
| <Composer> | Input + send; disabled while a turn is in flight. |
| <ProposalCard> | The propose→confirm gate; renders the result after confirm. |
| <MessageBubble> | A single message bubble (override via <CopilotThread renderMessage>). |
The pure core
For non-React (or custom-UI) callers, CopilotStore and the reducer are exported
directly:
import { CopilotStore, reduceFrames, emptyAccumulator } from '@vxil/react/copilot';
const store = new CopilotStore({ client: vx.copilot, agentId: 'support' });
store.subscribe(() => render(store.getState()));
await store.send('hello');Styling is class-based (.vxil-copilot-*) and unopinionated — bring your own CSS.
@vxil/react/feed
React activity streams + in-app notification feeds — a GetStream-Feeds-style
surface (timelines, follows, aggregation, a notification badge, and live
updates) over the shipped activity-feed REST + realtime APIs.
Build a Vxil SDK client for the signed-in end user (it holds a public/thin-client
key + the user's session token so every read/write is owner-scoped at the edge),
then pass its activityFeed namespace to <FeedProvider>.
import { Vxil } from '@vxil/sdk';
import { FeedProvider, FeedTimeline, NotificationBell, feedRealtimeTokenProvider } from '@vxil/react/feed';
const vx = Vxil.connect({ apiKey: PUBLIC_KEY, endUserToken: sessionToken });
function App() {
return (
<FeedProvider
client={vx.activityFeed}
userId="u_ada"
// OPTIONAL: enable live updates. Mints a scoped realtime token per channel
// via the same end-user client — the browser never needs the tenant key.
realtimeTokenProvider={feedRealtimeTokenProvider(vx.activityFeed, 'u_ada')}
>
<NotificationBell />
<FeedTimeline group="timeline" feedId="u_ada" aggregate />
</FeedProvider>
);
}Components (content is always rendered as text — the XSS floor; every row is overridable via render props):
<FeedProvider>— holds the client, the userId, and a live realtime client. Cleans up all realtime subscriptions on unmount.<FeedTimeline group feedId>— a feed's activities with keyset (infinite) pagination and optional GetStream-style group aggregation.<NotificationBell>— the unread badge (capped, e.g.99+) + a dropdown of unread groups + mark-all-read.
Hooks:
useActivityFeed({ group, feedId })—{ activities, groups, aggregated, hasMore, refresh, paginate, addActivity, removeActivity, follow, unfollow, block, unblock }, live-merged from new-activity frames.useNotifications()—{ badge, groups, preferences, markSeen, markRead, markAll, archive, refreshBadge, loadPreferences, setPreferences }, live-merged from the badge channel.
Pure core (no React, exported for custom UIs / testing):
FeedStore— keyset pagination + realtime merge (a new activity prepends exactly once; de-duped on id).NotificationStore— the{ unseen, unread, total }badge; mark-seen/read/archive adopt the server-authoritative badge; the realtimefeeds.unread-countframe merges in.aggregateActivities(activities)— collapse consecutive same verb+object+day activities into "X and N others …" groups.- Channel naming helpers — a feed ref is
{group}:{feedId}; its live channel isfeeds-{group}:{feedId}. Never hand-roll the strip:channelForFeed('timeline:u_ada')→'feeds-timeline:u_ada',feedRefFromChannel('feeds-timeline:u_ada')→'timeline:u_ada'(null for a non-feed channel),parseFeedRef(ref)→{ group, feedId }, andfeedRealtimeTokenProvider(client, userId, { ttlSeconds? })— the ready-maderealtimeTokenProvidershown above (it mints one scoped token per channel through the end-user call).
Realtime is optional: without realtimeTokenProvider the components still read
and paginate; they just don't live-update until you refresh().
@vxil/react/comments
Embeddable React widgets over the Vxil comments and files features — a
threaded comment thread and a file uploader, drop-in with one provider.
Wrap your app in <CommentsProvider> with an end-user-scoped vxil client
(vx.asEndUser(sessionToken)) and the acting user id, then drop in the widgets:
import { Vxil } from '@vxil/sdk';
import { CommentsProvider, CommentThread, UploadWidget, Dropzone } from '@vxil/react/comments';
// A thin/public API key + the signed-in user's auth session token. The edge
// verifies the token and owner-scopes every read/write to that user.
const vx = new Vxil({ apiKey: PUBLIC_KEY }).asEndUser(sessionToken);
function PostPage({ postId, userId }) {
return (
<CommentsProvider vx={vx} authorId={userId}>
<CommentThread topic={`post:${postId}`} />
<Dropzone onUpload={(file) => console.log('uploaded', file.object_id)} />
</CommentsProvider>
);
}Comments
<CommentThread topic="…">— threaded list (top-level + replies), a composer, per-comment emoji reactions, and an author edit/delete affordance gated to the tenant's edit window. All user content renders as plain, escaped text (no HTML/markdown), the XSS floor.useComments(topic)— the headless hook powering the widget:{ comments, threads, post, edit, remove, toggleReaction, loadMore, canEdit, hasReacted, ... }. Mutations are optimistic and reconcile against the server (or roll back on error).
const c = useComments(`post:${postId}`);
await c.post('Nice write-up!');
await c.post('Agreed', someCommentId); // a reply
await c.toggleReaction(someCommentId, '👍');Files
<UploadWidget onUpload={fn}>— a button + progress bar;onUploadfires with the availableFileObject.<Dropzone onUpload={fn}>— a drag-and-drop wrapper (multi-file, sequential) over the same engine.useUpload()— the headless hook:{ upload, uploadFile, cancel, reset, phase, progress, file, error, busy }.
Uploads run the vxil files 3-step: createUploadUrl → PUT bytes → complete. The presigned PUT URL is used once inside the upload and never persisted. If the signature window elapses, the error surfaces as kind: 'expired' and the widget offers a one-tap Retry that mints a fresh URL. The browser build uses an XHR transport for granular upload progress.
const up = useUpload();
const file = await up.uploadFile(browserFile); // -> FileObjectError text is provider-supplied — treat it as untrusted
Some upload error text is provider-supplied: on a presigned-URL expiry
(kind: 'expired') the object store's own body may be surfaced on
up.error.message and forwarded to any onError you pass. That text comes from
the underlying object store, not from vxil — treat it as untrusted
text. The widget only ever renders it as an escaped text node (the XSS floor
holds), but if your app logs or re-displays onError text, do not inject it as
HTML and consider capping/normalizing it. (Non-expiry byte-upload failures use a
fixed byte upload failed (status N) message — no provider text is passed
through there.)
Styling
The widgets ship minimal inline layout so they render without a stylesheet. Every element carries a stable data-vxil-* / data-comment-id hook and accepts a className — bring your own CSS to theme them.
@vxil/react/entitlements
Feature gating over the Vxil payments integration (your own Stripe / Paddle / PayPal / RevenueCat account behind Vxil's credits & entitlements ledger). One hook answers "is this user entitled?" with three states, and two unstyled components render a paywall and a manage-subscription link.
The rule the subpath exists for: a paying user must never be shown the free
experience because a read failed. useEntitlement() returns
'entitled' | 'not_entitled' | 'unknown':
entitled/not_entitledcome only from a successful read.- Any throw, non-2xx, malformed body, or network failure is
unknown— nevernot_entitled. - Once a good answer exists it is kept through failures (
stale: true), so the UI renders the cached answer rather than free. An optionalmaxStaleMsdegrades a too-old cached answer tounknown— still nevernot_entitled.
import { Vxil } from '@vxil/sdk';
import { useEntitlement, Paywall, ManageSubscription } from '@vxil/react/entitlements';
// A thin-client key + the signed-in user's session token: the read is
// owner-bound to that user (the edge decides the user, not the query).
const vx = new Vxil({ apiKey: PUBLIC_KEY, endUserToken: sessionToken });
function ExportButton() {
const ent = useEntitlement({ client: vx.payments, entitlement: 'export', refreshMs: 60_000 });
// ent.state · ent.tier · ent.until · ent.asOf · ent.raw · ent.stale · ent.refresh()
if (ent.state === 'unknown') return <button disabled>Export</button>;
return ent.state === 'entitled' ? <button>Export</button> : <UpgradeNudge />;
}
function ProArea() {
return (
<>
<Paywall client={vx.payments} entitlement="pro" when="not_entitled">
<UpgradeCard />
</Paywall>
<ManageSubscription client={vx.payments} label="Manage billing" fallback={null} />
</>
);
}No SDK? Pass the raw transport instead — useEntitlement({ apiKey, endUserToken, fetch?, baseUrl? }) —
or set it once with <EntitlementProvider client|apiKey endUserToken userId? refreshMs?>
and omit it from every hook/component below.
The hook
useEntitlement({ client | apiKey+endUserToken, userId?, entitlement?, quota?, creditType?, decide?, refreshMs?, maxStaleMs?, read?, enabled? }) →
{ state, tier, until, asOf, raw, quotaValue, credits, stale, loading, error, lastGoodAt, failures, refresh() }
| Criterion | Entitled when |
| --- | --- |
| entitlement: 'pro' | the snapshot's entitlements include it |
| quota: 'seats' | quotas.seats > 0 (the value is on quotaValue) |
| creditType: 'scan' | the credit balance's available > 0 (on credits) |
| decide(ctx) | your predicate, replacing the above |
| (none) | the user holds at least one entitlement |
Several criteria are ANDed. userId is optional in end-user mode (the verified
session decides the user; nothing is sent) and required when a server-mode
client is passed.
Routes it calls
GET /v1/payments/entitlements— the snapshot ({ user_id, tier, entitlements, quotas }). Yourquota/creditTypecriteria are sent as?quota=/?credit_type=, so the route inlines that quota's value and that credit type's balance and the hook gets its whole answer in one call.since/until/as_of/environmentare read from the response when present.GET /v1/payments/credits/balance?credit_type=…— the fallback, used only withcreditTypeand only when the snapshot did not already inline it (e.g. an older server, or an SDK namespace that sends no read params).GET /v1/payments/customer-portal-url—<ManageSubscription>only. Answers{ url }today; an optionalkind('portal' | 'store' | 'none') is honored when present and inferred otherwise. A501(no hosted portal for this provider) renders thefallback, not an error.
The browser key for this subpath carries payments:read only. Consuming or
granting credits is server-only by design and unreachable from a thin client.
Components
| Export | What it is |
| --- | --- |
| <Paywall when entitlement? quota? creditType? result? fallback? render?> | Renders children when the state matches when (default 'not_entitled'; an array is allowed). unknown matches only when asked for — a failed read never opens a paywall. Pass result to reuse a hook result. |
| <ManageSubscription label? fallback? render? newTab?> | Resolves the portal link and renders <a data-vxil-portal-kind>; render({ url, kind, loading, error }) overrides. |
| useManageSubscription() | The headless version of the above. |
| EntitlementStore, decideEntitlement, normalizeSnapshot, resolvePortalLink, fetchEntitlementClient | The pure core, for non-React callers. |
@vxil/react/auth
Passwordless sign-in for a browser or mobile web app over the Vxil auth
feature — release one of two: the OTP flow, the magic-link flow, and
guest/anonymous sessions, plus the session pair's storage, rotation and
sign-out. Every flow is a pure, node-tested state machine; the hooks are thin.
import { Vxil } from '@vxil/sdk';
import { AuthProvider, OtpFlow, useAuthSession, localStorageStorage } from '@vxil/react/auth';
// The browser's key: a thin-client key carrying `auth:signin` (+ the read
// scopes the UI needs) — see "The browser key" below.
const vx = new Vxil({ apiKey: PUBLIC_KEY });
export function App() {
return (
<AuthProvider client={vx.auth} storage={localStorageStorage()}>
<Gate />
</AuthProvider>
);
}
function Gate() {
const s = useAuthSession(); // { status, session, userId, anonymous, token, signOut, refresh }
if (s.status === 'restoring') return null;
if (s.status !== 'signed_in') return <OtpFlow onSignedIn={() => {}} />;
// Hand the token to the rest of the app: vx.asEndUser(s.token)
return <button onClick={() => void s.signOut()}>Sign out</button>;
}No SDK? <AuthProvider apiKey={PUBLIC_KEY} fetch? baseUrl?> builds the
raw-fetch client.
Hooks
useOtpFlow({ cooldownSec?, onSignedIn? })→{ phase, email, error, attempts, cooldownUntil, cooldownRemainingMs, canRequest, request(email), verify(code), resend(), reset() }. Phases:idle → requesting → code_sent → verifying → signed_in. A wrong code stays incode_sent(retry); an exhausted guessing budget returns toidle(the code is burned —resend()); a429mirrors the server's resend cooldown locally (Retry-Afterwhen sent).useMagicLink({ consumeOnMount?, onSignedIn? })→{ phase, error, request(email, redirectUrl), consume(token), consumeFromUrl(url?), reset() }.redirectUrlis the https page in your app that receives?token=;consumeFromUrl()reads it from the location (query or hash router).useGuestSession({ auto? })→{ phase, session, error, ensure() }. Mints an anonymous session on mount when nothing is persisted; an existing session (guest or not) is reused, never shadowed. The session carriesanonymous: true.useAuthSession()→ the pair +signOut()/refresh()and the underlyingmanager(callmanager.adopt(result)from a custom sign-in).- The pure cores are exported for non-React callers:
SessionManager,OtpFlowStore,MagicLinkFlow,GuestSession,fetchAuthClient,mapAuthError,magicLinkTokenFromUrl.
Components
<OtpFlow labels… signedIn? renderError?>— one unstyled form: email → 6-digit code → done. Stablevxil-otp-*classes anddata-vxil-otp-phase/data-vxil-otp-errorhooks; bring your own CSS.
Token storage (injected)
<AuthProvider storage={…}> takes a TokenStorage — { get, set, clear } over
{ user_id, token, refresh_token, expires_at, anonymous }:
memoryStorage()— the default; nothing survives a reload.localStorageStorage(key?)— persists across reloads; falls back to memory when storage is unavailable.webStorage(sessionStorage, key?)for anyStorage-shaped object.
Persisting the pair in web storage is the usual SPA trade-off; keep the access token TTL short and let the manager rotate.
Refresh + sign-out
The SessionManager rotates via POST /v1/auth/sessions/refresh before
expires_at (refreshLeadMs, default 60 s; one in-flight refresh at a time),
stores the new pair and discards the old. A transient failure keeps the still-valid
session and retries (refreshRetryMs); an invalid_refresh ends the session.
signOut() calls POST /v1/auth/sessions/revoke { token } best-effort and
clears locally regardless — a sign-out never fails visibly because the network did.
Routes it calls
POST /v1/auth/otp/request · POST /v1/auth/otp/verify · POST /v1/auth/magic-link/request ·
POST /v1/auth/magic-link/verify · POST /v1/auth/anonymous/sign-in ·
POST /v1/auth/sessions/refresh · POST /v1/auth/sessions/revoke.
Errors
Every failure is mapped to one AuthFailure — { kind, code, status, message, retryAfterSec }
with kind ∈ invalid_code | attempts_exhausted | rate_limited | method_disabled | invalid_token | invalid_refresh | validation | email_dispatch_failed | forbidden | network | unknown —
whether it came from the SDK, the raw-fetch client, or a bare fetch TypeError.
forbidden is the key being rejected (missing / invalid / revoked, or missing
auth:signin) — an app-configuration problem, never something the end user can
fix by retyping the code.
The browser key — a public key never carries a write scope
The key you ship to a browser or phone is a sign-in key: it may obtain,
renew, verify and end its own session, and read what the signed-in user
owns. Mint it as an end_user_required-class key with auth:signin plus the
read scopes your UI needs (e.g. payments:read) — and never auth:write
(the administrative class: erase users, force-revoke other devices, list
sessions), never *:write / *:send / admin:*. assertPublicKeyScopes(scopes)
throws on a violation; publicKeyScopeViolations(scopes) lists them. Money
moves (consume / grant) are server-only regardless.
Not in this release (release two)
The server side is live: GET /v1/auth/sessions is owner-bound in end-user
mode, POST /v1/auth/sessions/{id}/revoke only finds a session the principal
owns (a foreign id is a 404, not an error that confirms it exists),
POST /v1/auth/sessions/revoke-all is a self operation in end-user mode, and
the step-up challenge routes ship. What is missing is the client binding —
these are simply not built yet, and the exports do not exist:
useDevices()— the signed-in user's device list (GET /v1/auth/sessions; that route isauth:read, so a browser key would need it alongsideauth:signin).<TakeOverDialog>/revokeDevice(sessionId)— force-revoke one of your own devices (POST /v1/auth/sessions/{id}/revoke— still the administrativeauth:writeclass, so today it is a call your server makes, not the browser).useStepUp()— the step-up re-auth challenge on the current session (POST /v1/auth/step-up/request·POST /v1/auth/step-up/verify).
Changelog
- 0.2.0 — 2026-09-11, published to npm. New subpaths
@vxil/react/entitlementsand@vxil/react/auth(release one);@vxil/react/feedgainschannelForFeed/feedRefFromChannel/parseFeedRef/feedRealtimeTokenProvider. - 0.1.0 — first publish:
./copilot,./feed,./comments.
Docs: vxil.com · Dashboard: vxil.com/dashboard
MIT © techmaker.io
