@shieldlabs-ai/react 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,55 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@shieldlabs-ai/react` are documented in this file. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [1.0.0] - 2026-09-30
10
+
11
+ ### Added
12
+
13
+ - `ShieldLabsProvider` with the props `publicKey`, `environment`, `scriptUrl`, `timeout`,
14
+ `autoLoad` and `checkOnLoad`: loads the agent once with `load()` of `@shieldlabs-ai/js`, in an effect
15
+ after the first render, and reports `status` (`loading`, `ready` or `error`) and `error`. Invalid
16
+ props and pages that are not a secure context also log a console warning. Changing an option
17
+ loads the agent for the new options.
18
+ - `autoLoad={false}` defers loading, for example until consent: nothing loads until `load()` is
19
+ called or `autoLoad` becomes `true`. Until then `useIdentify().identify()` resolves `null` at
20
+ once with a `not_initialized` error, and `runOnMount` ends the same way without running again
21
+ after `load()`. `useShieldLabs().identify()` rejects with `not_initialized`, `check()` resolves
22
+ `null` and `getAgent()` waits, with no timeout of its own.
23
+ - `useShieldLabs()`: `status`, `error`, `identify()`, `check()`, `load()` and `getAgent()` of the
24
+ closest provider. `getAgent()` resolves the loaded agent of `@shieldlabs-ai/js`, for early
25
+ identification with `agent.identifyOnInteraction(form)`. Calls made while the agent loads wait
26
+ for it, and the call timeout covers the whole call: that wait, then the agent's answer in the
27
+ time that is left. A failed or timed-out load is tried again by the next call.
28
+ - `useIdentify({ userId, runOnMount })`: `identify()`, `result`, `isLoading`, `error` and
29
+ `reset()`. `identify()` resolves the result, or `null` with the reason in `error`, and never
30
+ rejects. A call with the same User HID and `timeout` as a running call of the same hook returns
31
+ that call, so a double submit costs one identification; a call without `timeout` counts as one
32
+ with the provider `timeout`. Options of `identify()` with a `userId` key override the hook
33
+ option, also with `undefined` or `null`, which both identify anonymously; only options without
34
+ the key use the User HID of the hook. The state follows the call made last.
35
+ - `checkOnLoad` runs `check()` once per provider mount when the agent is ready, unless an
36
+ `identify()` or `check()` for the same User HID is running at that moment, and `runOnMount` runs
37
+ `identify()` once per component mount. Both run once in StrictMode.
38
+ - Server rendering that touches neither `window` nor `document`, no state updates after unmount,
39
+ state that stays current inside a hidden `<Activity>` (React 19.2 and later), and hooks outside
40
+ the provider throw an error that names the hook.
41
+ - Re-exports of `ShieldLabsError` and the types `IdentifyOptions`, `IdentifyResult`,
42
+ `InteractionIdentifier`, `LoadOptions`, `ShieldLabsAgent` and `ShieldLabsErrorCode` from
43
+ `@shieldlabs-ai/js`.
44
+ - ESM, CommonJS and TypeScript declarations with a `"use client"` directive. Peer dependencies:
45
+ `react` 18 or 19 and `@shieldlabs-ai/js` 1.x.
46
+ - `examples/vite`: a signup form that starts an identification on the first interaction with
47
+ `identifyOnInteraction()` and sends the `requestId` with the submit.
48
+
49
+ ### Removed
50
+
51
+ - The placeholder `useShieldLabs(options)` hook of the pre-release scaffold, which returned `data`.
52
+ The browser receives a request ID; results are read on your server.
53
+
54
+ [Unreleased]: https://github.com/ShieldLabs-ai/shieldlabs-react/compare/v1.0.0...HEAD
55
+ [1.0.0]: https://github.com/ShieldLabs-ai/shieldlabs-react/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ShieldLabs Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,514 @@
1
+ # @shieldlabs-ai/react
2
+
3
+ React bindings for ShieldLabs device intelligence: a provider that loads the ShieldLabs agent once,
4
+ and hooks that return a request ID for every identification, with loading and error state.
5
+
6
+ [![CI](https://github.com/ShieldLabs-ai/shieldlabs-react/actions/workflows/ci.yml/badge.svg)](https://github.com/ShieldLabs-ai/shieldlabs-react/actions/workflows/ci.yml)
7
+ [![npm](https://img.shields.io/npm/v/@shieldlabs-ai/react)](https://www.npmjs.com/package/@shieldlabs-ai/react)
8
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
9
+
10
+ `@shieldlabs-ai/react` is a thin layer over [`@shieldlabs-ai/js`](https://github.com/ShieldLabs-ai/shieldlabs-js),
11
+ the browser loader that imports the hosted agent from `https://cdn.shieldlabs.ai` at runtime. It
12
+ supports React 18 and 19, renders on the server without touching browser globals, and loads the
13
+ agent once per app, also in StrictMode.
14
+
15
+ New to ShieldLabs? [Start free](https://app.shieldlabs.ai), then copy the Public Key of your domain
16
+ from Integration > API keys in the analytics dashboard (the Install tab also shows a ready snippet
17
+ that contains it).
18
+
19
+ ## How it fits
20
+
21
+ 1. **Browser.** `ShieldLabsProvider` loads the agent, and `useIdentify()` runs an identification for
22
+ a protected action. The page receives a `requestId`.
23
+ 2. **Your backend.** It receives the `requestId` with the protected action (signup, login,
24
+ checkout) and reads the verdict for it from the History API with a ShieldLabs server SDK, or
25
+ receives it in a signed `identification.scored` webhook.
26
+ 3. **Decision.** Your backend acts on the Risk Score (bands: trusted 0-29, suspicious 30-59,
27
+ dangerous 60-100), the detection flags and identifiers such as the device ID.
28
+
29
+ The browser only ever gets the request ID. The Risk Score, risk signals, detection flags, visitor ID
30
+ and device ID are read on your server, with one of the server SDKs:
31
+ [Node.js](https://github.com/ShieldLabs-ai/shieldlabs-node),
32
+ [Python](https://github.com/ShieldLabs-ai/shieldlabs-python),
33
+ [Go](https://github.com/ShieldLabs-ai/shieldlabs-go),
34
+ [PHP](https://github.com/ShieldLabs-ai/shieldlabs-php),
35
+ [Java](https://github.com/ShieldLabs-ai/shieldlabs-java) or
36
+ [.NET](https://github.com/ShieldLabs-ai/shieldlabs-dotnet).
37
+
38
+ The `identification.scored` webhook is delivered once per identification today (1-second timeout,
39
+ no retries). Use the History API when you need a guaranteed read, and make webhook handlers
40
+ idempotent on `data.request_id`, because future retries will resend identical bytes.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ npm install @shieldlabs-ai/react @shieldlabs-ai/js
46
+ # or
47
+ yarn add @shieldlabs-ai/react @shieldlabs-ai/js
48
+ # or
49
+ pnpm add @shieldlabs-ai/react @shieldlabs-ai/js
50
+ ```
51
+
52
+ `@shieldlabs-ai/js` (1.x) and `react` (18 or 19) are peer dependencies.
53
+
54
+ ## Quick start
55
+
56
+ The snippets use Vite with TypeScript; other bundlers expose environment variables their own way.
57
+ Put the Public Key of your domain in `.env` (the value below is a placeholder):
58
+
59
+ ```bash
60
+ # .env
61
+ VITE_SHIELDLABS_PUBLIC_KEY=0123456789abcdef0123456789abcdef
62
+ ```
63
+
64
+ Render `ShieldLabsProvider` once, near the root of your app, around the components that identify:
65
+
66
+ ```tsx
67
+ // main.tsx
68
+ import { StrictMode } from 'react';
69
+ import { createRoot } from 'react-dom/client';
70
+ import { ShieldLabsProvider } from '@shieldlabs-ai/react';
71
+ import { SignupForm } from './SignupForm';
72
+
73
+ createRoot(document.getElementById('root')!).render(
74
+ <StrictMode>
75
+ <ShieldLabsProvider publicKey={import.meta.env.VITE_SHIELDLABS_PUBLIC_KEY}>
76
+ <SignupForm />
77
+ </ShieldLabsProvider>
78
+ </StrictMode>,
79
+ );
80
+ ```
81
+
82
+ Run an identification when the user submits a protected action, and send the `requestId` with it:
83
+
84
+ ```tsx
85
+ // SignupForm.tsx
86
+ import type { SyntheticEvent } from 'react';
87
+ import { useIdentify } from '@shieldlabs-ai/react';
88
+
89
+ export function SignupForm() {
90
+ const { identify, isLoading } = useIdentify();
91
+
92
+ async function onSubmit(event: SyntheticEvent<HTMLFormElement>) {
93
+ event.preventDefault();
94
+ const email = new FormData(event.currentTarget).get('email');
95
+ // null when there is no identification (the reason is in `error`). The signup goes out anyway,
96
+ // and your server treats it as unverified.
97
+ const result = await identify();
98
+ await fetch('/api/signup', {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({ email, requestId: result?.requestId ?? null }),
102
+ });
103
+ }
104
+
105
+ return (
106
+ <form onSubmit={onSubmit}>
107
+ <input name="email" type="email" required />
108
+ <button disabled={isLoading}>Sign up</button>
109
+ </form>
110
+ );
111
+ }
112
+ ```
113
+
114
+ `identify()` never rejects: it resolves the result, or `null` with the reason in `error`. A second
115
+ submit while the identification runs gets the same one, so a double click costs one identification.
116
+
117
+ On your server, read the verdict for `requestId` with a server SDK, for example
118
+ `identifications.get(requestId)` in [`@shieldlabs-ai/node`](https://github.com/ShieldLabs-ai/shieldlabs-node),
119
+ which waits until the identification has been scored. The History row appears about 1-3 seconds
120
+ after `identify()` resolves and can be refined for up to about 10 seconds as follow-up checks
121
+ finish, so starting the identification when the user begins the action (see
122
+ [Protect a form](#protect-a-form)) gets your server the verdict sooner. Accept each request ID once
123
+ and only within your freshness window (the examples use 5 minutes): one identification authorizes
124
+ one protected action.
125
+
126
+ > **Keep the page alive after `identify()` resolves.** The agent posts the identification right
127
+ > after it hands over the request ID. Sending your request with `fetch()`, as above, keeps the page
128
+ > open. If you navigate right after the submit (a full-page form post or a redirect), start the
129
+ > identification early instead (see [Protect a form](#protect-a-form)).
130
+
131
+ > **Test on a registered domain.** ShieldLabs records identifications only for the domains
132
+ > registered in your account. On `localhost` the page still receives a `requestId`, but the
133
+ > identification is rejected with `401` and your backend never finds it. Test on a development
134
+ > domain with its own keys, as described in [Environments](https://docs.shieldlabs.ai/setup/environments).
135
+
136
+ ## Guide
137
+
138
+ ### Protect a form
139
+
140
+ `identify()` on submit, as in the quick start, is enough for most single-page apps. To have the
141
+ identification finished by the time the user submits, start it on the first interaction with the
142
+ form. `getAgent()` from `useShieldLabs()` resolves the loaded agent of `@shieldlabs-ai/js`, and its
143
+ `identifyOnInteraction(form)` starts `identify()` on the first `focusin`, `pointerdown` or `keydown`
144
+ inside the form. The handle's `take()` returns that identification for this submission and re-arms,
145
+ so the next submission gets its own request ID:
146
+
147
+ ```tsx
148
+ import { useEffect, useRef, type SyntheticEvent } from 'react';
149
+ import { useIdentify, useShieldLabs, type InteractionIdentifier } from '@shieldlabs-ai/react';
150
+
151
+ export function SignupForm() {
152
+ const { getAgent } = useShieldLabs();
153
+ const { identify } = useIdentify();
154
+ const formRef = useRef<HTMLFormElement>(null);
155
+ const early = useRef<InteractionIdentifier | null>(null);
156
+
157
+ useEffect(() => {
158
+ const form = formRef.current;
159
+ if (!form) return;
160
+ let active = true;
161
+ getAgent().then(
162
+ (agent) => {
163
+ if (active) early.current = agent.identifyOnInteraction(form);
164
+ },
165
+ () => {}, // the agent could not load: the submit handler tries again
166
+ );
167
+ return () => {
168
+ active = false;
169
+ early.current?.dispose();
170
+ early.current = null;
171
+ };
172
+ }, [getAgent]);
173
+
174
+ async function onSubmit(event: SyntheticEvent<HTMLFormElement>) {
175
+ event.preventDefault();
176
+ const email = new FormData(event.currentTarget).get('email');
177
+ // The early identification while it is fresh, otherwise a new one. Without an early handle,
178
+ // identify() loads the agent again; it resolves null when there is no identification.
179
+ const result = early.current ? await early.current.take().catch(() => null) : await identify();
180
+ await fetch('/api/signup', {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ email, requestId: result?.requestId ?? null }),
184
+ });
185
+ }
186
+
187
+ return (
188
+ <form ref={formRef} onSubmit={onSubmit}>
189
+ <input name="email" type="email" required />
190
+ <button>Sign up</button>
191
+ </form>
192
+ );
193
+ }
194
+ ```
195
+
196
+ For a classic full-page post, put the request ID in a hidden field
197
+ (`<input type="hidden" name="requestId" />` in a `<form method="post" action="/signup">`) and submit
198
+ the form yourself:
199
+
200
+ ```tsx
201
+ async function onSubmit(event: SyntheticEvent<HTMLFormElement>) {
202
+ event.preventDefault();
203
+ const form = event.currentTarget;
204
+ const result = await early.current?.take().catch(() => null);
205
+ (form.elements.namedItem('requestId') as HTMLInputElement).value = result?.requestId ?? '';
206
+ form.submit();
207
+ }
208
+ ```
209
+
210
+ Because the identification starts on the first interaction, it has normally finished posting by the
211
+ time the user submits. If users can submit without interacting first (for example autofill and a
212
+ single click on the button), prefer sending the form with `fetch()`, which keeps the page alive.
213
+
214
+ `take()` hands out the early identification only while it is fresh. When it failed, or finished more
215
+ than 4 minutes ago, `take()` starts a new one, so the request ID your server receives stays inside a
216
+ 5-minute freshness window. While users keep interacting with the form, a new identification starts
217
+ at most every 4 minutes (after a failure, at most one attempt every 5 seconds), and each of them is
218
+ billed. The effect's cleanup removes the listeners when the form unmounts. With
219
+ `autoLoad={false}` (see [Consent](#consent)), `getAgent()` waits for `load()`, so the form is armed
220
+ once `load()` has been called and the agent has loaded.
221
+
222
+ [`examples/vite`](./examples/vite) is a complete signup form built this way.
223
+
224
+ ### Signed-in users: pass a User HID
225
+
226
+ Pass a User HID so ShieldLabs ties the identification to the account. Compute it **on your server**
227
+ from your account ID with a secret key, for example with the `userHid(userId, secret)` helper of
228
+ the server SDKs (HMAC-SHA256, 64 hex characters), and hand it to the page, for example in your
229
+ session data:
230
+
231
+ ```tsx
232
+ const { identify } = useIdentify({ userId: session.userHid });
233
+ // Later, for the protected action. identify({ userId }) overrides the User HID for one call.
234
+ const result = await identify(); // result?.userId is the User HID that was sent
235
+ ```
236
+
237
+ Options of `identify()` with a `userId` key override the User HID of the hook for that call, also
238
+ when the value is `undefined` or `null`: `identify({ userId: undefined })` identifies anonymously.
239
+ Only options without the key use the User HID of the hook.
240
+
241
+ Never pass a raw email address, phone number or database ID. Omit `userId` for visitors who are not
242
+ signed in. The rules for the value (reserved values, characters that are hard to search) are in the
243
+ [`@shieldlabs-ai/js` guide](https://github.com/ShieldLabs-ai/shieldlabs-js#signed-in-users-pass-a-user-hid).
244
+
245
+ ### Identify when a component mounts
246
+
247
+ `useIdentify({ runOnMount: true })` runs `identify()` once when the component mounts, as soon as the
248
+ agent is ready. `isLoading` is `true` from the first render. Re-renders, prop changes and StrictMode
249
+ do not run it again; a new mount of the component does. With `autoLoad={false}`, a mount before
250
+ `load()` ends at once with a `not_initialized` error and does not run again after `load()` (see
251
+ [Consent](#consent)). Every run is a billable identification, so use it for a component that is
252
+ itself the protected step, never in a layout, a list item or a component that mounts on every route.
253
+
254
+ `result` is one identification, and one identification authorizes one protected action: send its
255
+ `requestId` with a single request, within your freshness window (5 minutes in the examples). Your
256
+ server rejects a request ID it has seen before or one that is too old. For every later action (a
257
+ retry after a declined card, a second submit, a user who comes back after a break), call
258
+ `identify()` again. For form submits, `identify()` in the submit handler, as in the quick start, is
259
+ the simpler choice.
260
+
261
+ ```tsx
262
+ function RecoveryStep({ userHid }: { userHid: string }) {
263
+ const { result, isLoading } = useIdentify({ userId: userHid, runOnMount: true });
264
+ if (isLoading) return <p>Loading</p>;
265
+ // The request ID goes with the one request that loads the recovery options. Without a result (the
266
+ // identification failed), that request is sent without a requestId.
267
+ return <RecoveryOptions requestId={result?.requestId ?? null} />;
268
+ }
269
+ ```
270
+
271
+ ### Background checks with `checkOnLoad`
272
+
273
+ `checkOnLoad` runs `check()` once per provider mount when the agent is ready, for passive monitoring
274
+ of the visit. `true` checks anonymously, `{ userId }` passes a User HID:
275
+
276
+ ```tsx
277
+ <ShieldLabsProvider publicKey={publicKey} checkOnLoad={session ? { userId: session.userHid } : true}>
278
+ <App />
279
+ </ShieldLabsProvider>
280
+ ```
281
+
282
+ The agent limits `check()` to one identification per visit every five minutes, shared across tabs.
283
+ The check uses the value of `checkOnLoad` at the moment the agent becomes ready, and changes after
284
+ that do not run it again. It is skipped when an `identify()` or `check()` for the same User HID is
285
+ still running at that moment (for example `runOnMount`, or a submit made while the agent loaded):
286
+ that call already identifies the visit, and the agent runs one identification at a time for a User
287
+ HID. A skipped or failed check is ignored (invalid options log a console warning). Your backend sees
288
+ these identifications like any other; to get the request ID in the page, call `check()` from
289
+ `useShieldLabs()` instead, which resolves `null` when the agent skipped it.
290
+
291
+ ### Loading and error state
292
+
293
+ `useShieldLabs()` returns the agent status, the provider's `identify()` and `check()`, `load()`
294
+ and `getAgent()`:
295
+
296
+ ```tsx
297
+ function AgentStatus() {
298
+ const { status, error } = useShieldLabs();
299
+ if (status === 'error') return <small>Identification is unavailable ({error?.code}).</small>;
300
+ return null;
301
+ }
302
+ ```
303
+
304
+ You do not need to wait for `'ready'`: `identify()` and `check()` called while the agent loads wait
305
+ for it. The call's `timeout` (default: the provider `timeout`, 10 seconds) covers the whole call:
306
+ that wait and then the agent's answer, which gets only the time that is left. Never block a
307
+ protected action on the status. When the agent cannot load (a content blocker, a network error),
308
+ send the action without a `requestId`; your backend treats it as unverified. A failed or timed-out
309
+ load is tried again by the next `identify()`, `check()`, `getAgent()` or `load()`, and the status
310
+ follows. When the provider props are invalid (for example a missing `publicKey` because an
311
+ environment variable is not set) or the page is not a secure context, the provider also logs a
312
+ console warning that starts with `[ShieldLabs]`.
313
+
314
+ `identify()` and `check()` of `useShieldLabs()` are the calls of `@shieldlabs-ai/js`: `identify()`
315
+ rejects with a `ShieldLabsError` when there is no identification. `useIdentify()` wraps it with
316
+ `result`, `isLoading` and `error`, never rejects and shares a running call.
317
+
318
+ ### Server-side rendering and StrictMode
319
+
320
+ - The provider and the hooks render on the server. Nothing touches `window` or `document` during
321
+ render; the agent loads in an effect after hydration (with `autoLoad={false}`, once `load()` is
322
+ called). The server HTML shows `status: 'loading'` (and `isLoading: true` for `runOnMount`).
323
+ - In StrictMode the provider loads the agent once, and `runOnMount` and `checkOnLoad` run once per
324
+ mount. `load()` of `@shieldlabs-ai/js` is memoized per agent URL and Public Key, so mounting the
325
+ provider again reuses the agent that is already loaded.
326
+ - The hooks never identify on re-renders or on route changes. Keep the provider above your router so
327
+ that navigation does not remount it.
328
+ - Inside `<Activity mode="hidden">` (React 19.2 and later) the provider and the hooks keep their
329
+ state. A load or an identification that finishes while the content is hidden shows up when it is
330
+ visible again, and showing it again does not run `runOnMount` or `checkOnLoad` again.
331
+ - The built files start with the `"use client"` directive, so bundlers for React Server Components
332
+ treat the package as client code.
333
+
334
+ ### Next.js
335
+
336
+ Use [`@shieldlabs-ai/next`](https://github.com/ShieldLabs-ai/shieldlabs-next). It provides this
337
+ provider and these hooks as a client module for the App Router (the Pages Router is documented
338
+ there) and adds server helpers in `@shieldlabs-ai/next/server` for reading identifications and
339
+ verifying webhooks in route handlers.
340
+
341
+ ### Call budget and Content Security Policy
342
+
343
+ The rules of `@shieldlabs-ai/js` apply unchanged:
344
+
345
+ - [Call budget](https://github.com/ShieldLabs-ai/shieldlabs-js#call-budget): one identification per
346
+ protected action, and a small per-IP budget on the ingest. Never clear the agent's storage.
347
+ - [Content Security Policy](https://github.com/ShieldLabs-ai/shieldlabs-js#content-security-policy):
348
+ the `script-src` and `connect-src` origins the agent needs.
349
+
350
+ ### Consent
351
+
352
+ The agent does not read your consent banner (see
353
+ [Consent](https://github.com/ShieldLabs-ai/shieldlabs-js#consent) in the `@shieldlabs-ai/js` guide).
354
+ Where your policy requires consent before the agent loads, render the provider with
355
+ `autoLoad={false}`: nothing loads until `load()` from `useShieldLabs()` is called, or until
356
+ `autoLoad` becomes `true`.
357
+
358
+ ```tsx
359
+ import { ShieldLabsProvider, useShieldLabs } from '@shieldlabs-ai/react';
360
+ import { SignupForm } from './SignupForm';
361
+
362
+ export function App() {
363
+ return (
364
+ <ShieldLabsProvider publicKey={import.meta.env.VITE_SHIELDLABS_PUBLIC_KEY} autoLoad={false}>
365
+ <ConsentBanner />
366
+ <SignupForm />
367
+ </ShieldLabsProvider>
368
+ );
369
+ }
370
+
371
+ function ConsentBanner() {
372
+ const { load } = useShieldLabs();
373
+ // Record the choice as your consent tool requires, then load the agent.
374
+ return <button onClick={load}>Accept</button>;
375
+ }
376
+ ```
377
+
378
+ When your consent state lives in React already, pass it instead:
379
+ `<ShieldLabsProvider publicKey={publicKey} autoLoad={consentGiven}>`. Once loading has started,
380
+ calls wait for the agent as usual.
381
+
382
+ Until then, the rest of the app works unchanged and nothing waits for consent:
383
+
384
+ - `identify()` from `useIdentify()` resolves `null` at once, with a `not_initialized` error, so
385
+ forms go out without a `requestId` and your server treats them as unverified. `runOnMount` ends
386
+ the same way and does not run again by itself after `load()`.
387
+ - `useShieldLabs().identify()` rejects with `not_initialized`, and `check()` resolves `null`.
388
+ - `getAgent()` waits, with no timeout of its own, so a form set up for early identification (see
389
+ [Protect a form](#protect-a-form)) is armed once `load()` has been called and the agent has
390
+ loaded. When that load fails, `getAgent()` rejects with its error. `checkOnLoad` runs once the
391
+ agent is ready.
392
+ - `status` stays `'loading'`.
393
+
394
+ Setting `autoLoad` back to `false` does not unload an agent that has loaded.
395
+
396
+ ## Reference
397
+
398
+ | Export | Description |
399
+ |---|---|
400
+ | `ShieldLabsProvider` | Loads the agent once and provides it to the hooks below it |
401
+ | `useShieldLabs()` | Agent status, load error, `identify()`, `check()`, `load()` and `getAgent()` of the closest provider |
402
+ | `useIdentify(options?)` | Identification with `result`, `isLoading`, `error` and `reset()`. Its `identify()` resolves `null` instead of rejecting |
403
+ | `ShieldLabsError` | The error class of `@shieldlabs-ai/js` (re-exported). Has `code` and optional `cause` |
404
+ | Types | `ShieldLabsProviderProps`, `ShieldLabsStatus`, `UseShieldLabsResult`, `UseIdentifyOptions`, `UseIdentifyResult`, and from `@shieldlabs-ai/js`: `IdentifyOptions`, `IdentifyResult`, `InteractionIdentifier`, `LoadOptions`, `ShieldLabsAgent`, `ShieldLabsErrorCode` |
405
+
406
+ `<ShieldLabsProvider>` props
407
+
408
+ | Prop | Type | Default | Description |
409
+ |---|---|---|---|
410
+ | `publicKey` | `string` | required | Public Key of your domain |
411
+ | `environment` | `'production' \| 'development'` | `'production'` | Which ShieldLabs CDN to load the agent from |
412
+ | `scriptUrl` | `string` | | Advanced: agent module URL override (`https`, or `http` on `localhost` and `127.0.0.1`) |
413
+ | `timeout` | `number` | `10000` | Milliseconds to wait for the agent to load, and the default timeout of each `identify()` and `check()` call |
414
+ | `autoLoad` | `boolean` | `true` | Loads the agent after the first render. With `false`, nothing loads until `load()` is called or `autoLoad` becomes `true` (see [Consent](#consent)) |
415
+ | `checkOnLoad` | `boolean \| { userId?: string }` | `false` | Runs `check()` once per provider mount when the agent is ready, unless a call for the same User HID is running |
416
+ | `children` | `ReactNode` | | Your app |
417
+
418
+ Changing `publicKey`, `environment`, `scriptUrl` or `timeout` loads the agent for the new options
419
+ (once loading is allowed), and the status goes back to `'loading'`.
420
+
421
+ `useShieldLabs()` returns
422
+
423
+ | Field | Type | Description |
424
+ |---|---|---|
425
+ | `status` | `'loading' \| 'ready' \| 'error'` | `'loading'` until the agent has loaded, then `'ready'`, or `'error'` when loading failed |
426
+ | `error` | `ShieldLabsError \| null` | Why loading failed while `status` is `'error'` |
427
+ | `identify(options?)` | `Promise<IdentifyResult>` | Fresh identification, a new request ID on every call. Waits for the agent while it loads. Rejects with a `ShieldLabsError` |
428
+ | `check(options?)` | `Promise<IdentifyResult \| null>` | Background check, limited by the agent to one per visit every five minutes. `null` when skipped |
429
+ | `load()` | `void` | Starts loading the agent: needed only with `autoLoad={false}`. Also loads again after a failed load. Safe to call more than once; call it from an event handler or an effect |
430
+ | `getAgent()` | `Promise<ShieldLabsAgent>` | The loaded agent of `@shieldlabs-ai/js`, for example for `identifyOnInteraction(form)`. Waits while the agent loads (with `autoLoad={false}`, until `load()`), with no timeout of its own. Rejects with the load error |
431
+
432
+ `useIdentify(options?)`
433
+
434
+ | Option | Type | Default | Description |
435
+ |---|---|---|---|
436
+ | `userId` | `string` | | User HID for every identification of this hook. Options of `identify()` with a `userId` key override it, also with `undefined` or `null` (an anonymous identification) |
437
+ | `runOnMount` | `boolean` | `false` | Runs `identify()` once when the component mounts, as soon as the agent is ready |
438
+
439
+ | Field | Type | Description |
440
+ |---|---|---|
441
+ | `identify(options?)` | `Promise<IdentifyResult \| null>` | Starts a new identification and resolves its result, or `null` when there is none (the reason is in `error`). Never rejects. While a call of this hook with the same User HID and `timeout` runs, returns that call instead of starting another (a call without `timeout` counts as one with the provider `timeout`) |
442
+ | `result` | `IdentifyResult \| null` | Result of the latest identification. `null` while a new one runs, when it failed and after `reset()` |
443
+ | `isLoading` | `boolean` | `true` while the latest identification runs |
444
+ | `error` | `ShieldLabsError \| null` | Why the latest identification failed |
445
+ | `reset()` | `void` | Clears `result` and `error`. A running identification no longer updates the state, and the next `identify()` starts a new one |
446
+
447
+ The state follows the call made last. A call with another User HID or `timeout` than a running one
448
+ starts its own identification. The User HID of a call is the `userId` of its options when they have
449
+ that key (`undefined` and `null` both mean anonymous), else the `userId` of the hook: in a hook with
450
+ a User HID, `identify()` and `identify({ userId: undefined })` are two identifications. A call
451
+ without `timeout` counts as one with the provider `timeout` (10 seconds by default), so `identify()`
452
+ and `identify({ timeout: 10000 })` share one identification. Two `useIdentify()` hooks never share
453
+ a call. The functions keep their identity across renders (`identify` changes when `userId`
454
+ changes), so they are safe in effect dependencies.
455
+
456
+ `IdentifyOptions` (from `@shieldlabs-ai/js`)
457
+
458
+ | Option | Type | Description |
459
+ |---|---|---|
460
+ | `userId` | `string` | User HID computed on your server. Omit for anonymous checks |
461
+ | `timeout` | `number` | Milliseconds the whole call may take: a wait for the agent to load, then the agent's answer in the time that is left. Overrides the provider `timeout`, which also limits the load itself |
462
+
463
+ `IdentifyResult` (from `@shieldlabs-ai/js`)
464
+
465
+ | Field | Type | Description |
466
+ |---|---|---|
467
+ | `requestId` | `string` | Send it to your backend with the protected action |
468
+ | `userId` | `string \| null` | The User HID used, `null` for anonymous checks |
469
+
470
+ ## Errors and retries
471
+
472
+ Every error is a `ShieldLabsError`. `useIdentify().identify()` stores it in `error` and resolves
473
+ `null`; the calls of `useShieldLabs()` reject with it. Branch on `error.code`:
474
+
475
+ | `code` | When | What happens and what to do |
476
+ |---|---|---|
477
+ | `invalid_options` | A provider prop or a call option failed validation, or `publicKey` holds a server-side secret | A bad prop sets `status` to `'error'` and logs a console warning; a bad call option fails that call. Fix the value; retrying does not help |
478
+ | `unsupported_environment` | The page is not a secure context | `status` is `'error'`, with a console warning. Serve the page over HTTPS (`localhost` and `127.0.0.1` also work over `http`) |
479
+ | `load_failed` | The agent module could not be imported (network error, content blocker, Content Security Policy) | `status` is `'error'`. Continue without an identification; the next `identify()`, `check()`, `getAgent()` or `load()` loads again |
480
+ | `timeout` | The agent did not load, or an agent call did not answer, within the timeout (default 10 seconds) | Continue without an identification. A load that timed out keeps running, and the next call uses it once it arrives |
481
+ | `not_initialized` | `identify()` only: the agent did not start an identification, for example because another one is running in this or another tab, or the provider has `autoLoad={false}` and `load()` has not been called. `check()` resolves `null` instead | Retry once later, or continue without an identification |
482
+
483
+ Whenever there is no identification, send the protected action anyway without a `requestId`: your
484
+ backend treats a missing identification as unverified (for example step-up or review), never as
485
+ clean. Calling a hook outside `ShieldLabsProvider` throws an `Error` that names the hook.
486
+
487
+ ## Compatibility
488
+
489
+ - React 18 and 19 (React DOM), with TypeScript types for both.
490
+ - Browsers: the same as `@shieldlabs-ai/js` (ES modules, dynamic `import()` and WebCrypto, in a secure
491
+ context).
492
+ - Server rendering with `react-dom/server` in Node.js 18 or later; the agent loads only in the
493
+ browser.
494
+ - Output: ES2019 syntax as ESM and CommonJS with TypeScript declarations, marked `"use client"`.
495
+ No dependencies besides the peer dependencies.
496
+
497
+ ## Development
498
+
499
+ ```bash
500
+ npm ci
501
+ # Until @shieldlabs-ai/js is on npm, install a local pack of it (see CONTRIBUTING.md):
502
+ npm install --no-save ../shieldlabs-js/shieldlabs-ai-js-1.0.0.tgz
503
+ npm run typecheck
504
+ npm run lint
505
+ npm test -- --coverage # builds first, then runs the tests
506
+ npm run build
507
+ ```
508
+
509
+ See [CONTRIBUTING.md](./CONTRIBUTING.md). Documentation: <https://docs.shieldlabs.ai>. Analytics
510
+ dashboard: <https://app.shieldlabs.ai>. Support: <contact@shieldlabs.ai>.
511
+
512
+ ## License
513
+
514
+ [MIT](./LICENSE), Copyright (c) 2026 ShieldLabs Inc.