@facepe/widgets
v1.7.3
Published
FacePe SDK: embeddable React + TypeScript components for host applications.
Readme
@facepe/widgets
The FacePe SDK: a reusable React + TypeScript library that a host application installs and compiles into its own bundle. It is not a standalone app — it has no page, no dev server and nothing to run on its own.
FacePe components will be exported from this package. At this stage it contains
only the package foundation and a minimal public entry point
(SDK_VERSION).
React comes from your application
react and react-dom are peer dependencies (React 18 or 19). The SDK does
not ship its own copy: the build leaves every react / react-dom import
external, so the host's React is the only one in the final bundle. A second copy
would break hooks.
Install dependencies
cd facepe-sdk
npm installThis also installs React as a dev dependency, used only for type checking and building the SDK itself.
Build
npm run buildThis runs these steps, and stops at the first failure:
lint-csschecks the stylesheets against the styling contract.vite buildcompilessrc/in library mode to JavaScript.tscgenerates the TypeScript declaration files (andemit-cjs-typestheir CommonJS copies).emit-schemasgenerates the JSON Schemas of the payload types.
Type check without building:
npm run typecheckBuild output
Everything is written to dist/, which is the only folder that is published:
| File | Purpose |
| --- | --- |
| dist/index.js | ES module build (import) |
| dist/index.cjs | CommonJS build (require) |
| dist/index.d.ts / dist/index.d.cts | TypeScript declarations (ESM / CommonJS) |
| dist/styles.css | Component styles, published as @facepe/widgets/styles.css |
| dist/schemas/*.schema.json | JSON Schemas of the payloads, published as @facepe/widgets/schemas/… |
| *.map | Source maps (with sources embedded) |
Using it from a React app
import { FacePeProvider, FacePeForm } from '@facepe/widgets';
import '@facepe/widgets/styles.css'; // once, anywhere in the app
<FacePeProvider config={{ mode: 'edit', locale: 'en-IN' }}>
<FacePeForm onSubmit={(event) => save(event.payload)} />
</FacePeProvider>Who can use FacePe: the access flow
Access is granted top-down, and every level is checked on every request:
Super admin creates the company, enables "FacePe SDK" and/or "FacePe API" for it,
and grants it avatars
Company admin creates users, assigns each user avatar_ids, and gives a user
SDK/API access: Dashboard → Users → SDK / API → access key + secret key
User's app its SERVER exchanges the keys for a token that lasts 2 hours; the SDK (or the
app's own API calls) use that token
Every call company allowed? → user allowed? → this avatar_id assigned? → allowedRevoking or regenerating a user's keys, disabling the user, or switching the company's access off takes effect on the next request, not when tokens expire.
The avatar: <FacePeAvatar />
The same avatar experience as on the FacePe website: the same avatar, trained prompt, voice and features, embedded in your page. The minimum setup:
import '@facepe/widgets/styles.css';
import { FacePeProvider, FacePeAvatar } from '@facepe/widgets';
<FacePeProvider
config={{}}
apiBaseUrl="https://api.facepe.com/api/v1/sdk"
getToken={({ forceRefresh }) => // YOUR server, below
fetch(forceRefresh ? '/facepe-token?refresh=1' : '/facepe-token')
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`token ${r.status}`))))}
>
<FacePeAvatar />
</FacePeProvider>No id is needed: with only the user's keys (on your server), the component
loads the agents assigned to that user and shows them to choose from (the
first is selected; with one agent there is no choice to make). An agent is an
avatar trained for one job (made on the FacePe dashboard's Agents page). To
show one particular agent instead, pass its agentId to the provider or the
component. (avatarId still works: it is the same id, from before agents.)
<FacePeAvatar> shows the avatar's portrait and a Start conversation
button (browsers allow sound and the microphone only after a click). It then
streams the avatar's video and voice, listens to the user's microphone, and
shows captions; Mute and End control the session. Props: agentId
(optional; overrides the provider's), language ('en', 'hi', …), autoStart,
captions, plus the usual emphasis, size, density. Events:
onSessionStart, onSessionEnd, onTranscript, onOrderUpdate, onError.
Handle: start(), end(), setMuted(muted), focus(). Slot: controls
replaces the built-in buttons.
The connection libraries are installed with the package and loaded in their own chunks only when an avatar is shown, so they cost nothing on other pages. An avatar is described only by its name, gender, portrait, description and languages: where it comes from is never part of the API or the SDK.
Synchronized Cart: <FacePeCart /> and useFacePeCart()
With an ordering agent (drive-thru, restaurant, pharmacy, retail, …), the
order the customer speaks fills <FacePeCart /> live through the shared
FacePeProvider — for every kind of avatar. Each line carries the menu item's
id and price from the agent's menu (useFacePeAgent().menu), and the cart
shows the company's currency.
- You choose who places the order. By default checkout only fires
onCheckoutwith the items, for your own backend. WithplaceOrder, checkout places the order with FacePe (the agent's menu prices and the company's tax), firesonOrderPlacedwith the order, and empties the cart; the order appears on the company's Orders page. - Your edits stay. The customer can change quantities on screen; the cart moves again only when the conversation changes the order.
- Agent limits apply. FacePe refuses an order over the agent's limits (per-item quantity, total items, order value) or with items not on its menu.
import { FacePeProvider, FacePeAvatar, FacePeCart } from '@facepe/widgets';
import '@facepe/widgets/styles.css';
export function OrderScreen() {
return (
<FacePeProvider config={{}} apiBaseUrl={API} getToken={fetchToken} agentId="<ordering agent id>">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 340px', gap: '20px' }}>
<FacePeAvatar />
<FacePeCart
placeOrder
onOrderPlaced={({ payload }) => {
console.log('Order', payload.orderNumber, 'total', payload.currencySymbol + payload.total);
}}
onError={({ payload }) => console.warn(payload.message)}
/>
</div>
</FacePeProvider>
);
}Without placeOrder, handle checkout yourself:
<FacePeCart
onCheckout={({ payload }) => {
// Send to your own payment or order system.
console.log('Final order:', payload.items, 'Total:', payload.totalAmount);
}}
/>You can also control the cart programmatically using the hook:
import { useFacePeCart } from '@facepe/widgets';
function CustomCartButton() {
const { items, totalQty, totalAmount, addItem, clearCart } = useFacePeCart();
return (
<button onClick={() => alert(`Items: ${totalQty}, Total: ₹${totalAmount}`)}>
Cart ({totalQty})
</button>
);
}Form agents: <FacePeAgentForm /> and useFacePeAgent()
A form agent asks the customer its own questions (set on the FacePe
dashboard). While they talk, <FacePeAvatar> fills that form live:
onFormUpdate fires with every question and the answers given so far, and
<FacePeAgentForm /> shows it filling in, with an upload button for each
document the agent asks for (JPG, PNG, WebP, HEIC or PDF, up to 10 MB; kept with
the conversation on FacePe).
import { FacePeProvider, FacePeAvatar, FacePeAgentForm } from '@facepe/widgets';
export function IntakeScreen() {
return (
<FacePeProvider config={{}} apiBaseUrl={API} getToken={fetchToken} agentId="<form agent id>">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<FacePeAvatar onFormUpdate={({ payload }) => console.log(payload.fields)} />
<FacePeAgentForm
onUpload={({ payload }) => console.log('Uploaded', payload.label)}
onError={({ payload }) => console.warn(payload.message)}
/>
</div>
</FacePeProvider>
);
}Drawing your own screen instead? useFacePeAgent() gives the same data and
actions anywhere inside the provider:
const { live, form, menu, uploads, uploadDocument, placeOrder } = useFacePeAgent();
// form: { title, complete, fields: [{ label, question, type, required, value, options?, minAge? }], documents, uploadWhere, phoneDigits? }
await uploadDocument('ID proof', file); // a File from <input type="file">
const order = await placeOrder({ notes: 'No onions' }); // the provider's cart
if (order.success) console.log(order.data.orderNumber);Correcting an answer. Speech recognition can mishear names and emails ("Susil" heard as
"Cecil"). The agent reads them back so the customer can correct or spell them, and each answer
in <FacePeAgentForm> has an Edit button; your own screen can call
useFacePeAgent().setFormAnswer('Full Name', 'Susil Kumar'). A corrected answer stays.
Dropdowns, dates and dates of birth. Each answer has the question's type:
select(Dropdown) has itsoptions. The agent asks the question without reading the options aloud; the customer says one or picks it on screen. In<FacePeAgentForm>the button is Choose and opens the list. A pick made withsetFormAnsweris told to a hosted avatar, which answers it as if it had been said.radio(Radio buttons) works like a dropdown, shown as radio buttons, andcheckbox(Checkboxes) takes one or more of itsoptions, kept as "A, B".- A company's own question type may carry a validation
rule(written on the FacePe dashboard) and its exact check,pattern:checkFormAnswerapplies it. date(Date & Time) isYYYY-MM-DD, orYYYY-MM-DD HH:MMwhen a time is given.dob(Date of Birth) is a real date in the past, at most 120 years ago, and at leastminAgeyears ago when the company set one. The agent refuses future dates and words like "tomorrow", and confirms a date said only in numbers with the month in words.
Check a typed answer before keeping it with checkFormAnswer(field, value, form.phoneDigits).
It returns { ok: true, value } (the answer as it is kept, e.g. the option's own spelling)
or { ok: false, message } to show the customer. <FacePeAgentForm> does this already, and
FacePe checks hand-typed answers again when the conversation ends: one that does not fit
keeps what was read from the speech.
In <FacePeAgentBuilder>, a Dropdown question takes its options (at least two), and a
Date of Birth question an optional minimum age (1–120). With useFacePeAgentAdmin(),
pass them as options and minAge on the question.
The final form still arrives in onConversationEnd (form) when the call ends.
Conversations in the company's Logs
When a call ends, FacePe keeps it in the company's Logs page, like conversations in the FacePe dashboard: the transcript, the form answers and uploaded documents, and the cart (or the order placed). The company sees it there with who started it (the user whose keys your app uses), when, and for how long. To keep only the call itself, turn it off:
<FacePeAvatar keepInLogs={false} />Creating and training agents: <FacePeAgentBuilder /> and useFacePeAgentAdmin()
Apps can manage the company's agents the way the FacePe dashboard's Agents page does: create an agent (a name and an avatar), train it, edit its instructions, switch it on or off, delete it, and choose which users have it. It is the same set of agents: an agent made in your app shows on the dashboard, and one made on the dashboard can be trained in your app. Each key works on its own company's agents only.
The ready-made screen — the agent list, the training screen and the users screen:
import { FacePeAgentBuilder } from '@facepe/widgets';
<FacePeAgentBuilder
onSave={(e) => console.log('saved', e.payload.name)}
onDelete={(e) => console.log('deleted', e.payload.agentId)}
/>Props: agentId (open that agent for training), startWithNew, allowDelete,
allowAssign (both default true). The handle has refresh() and startNew().
Or draw your own screens with the methods (each resolves to a Result, never throws):
const admin = useFacePeAgentAdmin();
const { data: options } = await admin.getOptions(); // avatars, business types, field types, menus, documents
const created = await admin.createAgent({
name: 'Reception',
avatarId: options.avatars[0].id,
form: {
formTitle: 'Visitor check-in',
fields: [
{ label: 'Full Name', question: 'May I have your full name?', type: 'text', required: true },
{ label: 'Phone Number', question: 'Your phone number?', type: 'phone', required: true },
],
documents: ['ID proof'],
},
});
// Edit the generated instructions — e.g. change "PHONE NUMBER DIGITS: 10" to 12:
await admin.updateAgent(created.data.id, { systemPrompt: created.data.systemPrompt.replace('PHONE NUMBER DIGITS: 10', 'PHONE NUMBER DIGITS: 12') });
await admin.setAgentUsers(created.data.id, ['<user id>']);The methods: getOptions, listAgents, getAgent, previewPrompt (the instructions a
training would give, before creating), createAgent, updateAgent, generatePrompt
(rebuild the instructions from the training), deleteAgent, getAgentUsers,
setAgentUsers. formOfAgent(agent) reads a form agent's questions back from its
saved training. A user with no agents assigned may use every agent of the company.
Avatar Voice Form Auto-Filling: <FacePeForm />
For agents without a form of their own: during conversational questionnaires, registration, or check-ins, the avatar extracts spoken details (such as full name, phone number (7 to 15 digits) with multiplier support like "double nine", email address, or special notes) from transcripts and live-populates <FacePeForm autoSyncWithAvatar />.
- Privacy & Local State: Extracted answers are updated directly in local state and validated. They are not forwarded to third-party endpoints automatically.
- Instant Visual Feedback: Synchronized inputs display an
"AI filled"badge so users can verify or edit answers manually. - Developer Submission Control: Your app handles
onSubmit={(e) => ...}oronValuesChange={(e) => ...}to save data directly into your own database or CRM.
import { FacePeProvider, FacePeAvatar, FacePeForm } from '@facepe/widgets';
export function VoiceRegistrationView() {
return (
<FacePeProvider config={{}} getToken={fetchToken}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
{/* Avatar listens to answers and emits field extraction events */}
<FacePeAvatar autoSyncForm={true} />
{/* Form fields automatically update live as the user speaks to the avatar */}
<FacePeForm
autoSyncWithAvatar={true}
onSubmit={({ payload }) => {
// Sent ONLY to YOUR backend:
console.log('Validated submission:', payload);
}}
onValuesChange={({ payload }) => {
console.log('Live field values:', payload);
}}
/>
</div>
</FacePeProvider>
);
}import { useFacePeFormSync } from '@facepe/widgets';
// Use anywhere inside FacePeProvider to observe extracted values
function StatusIndicator() {
const { extractedValues, lastUpdatedField } = useFacePeFormSync();
return lastUpdatedField ? (
<span>Auto-updated {lastUpdatedField} from speech</span>
) : null;
}Conversation data: onConversationEnd
When a conversation ends, <FacePeAvatar> hands your app the whole
conversation in one record. It stays in your app — the SDK sends it nowhere;
you decide whether to show it, save it on your server, or ignore it.
<FacePeAvatar
onConversationEnd={(e) => {
const conversation = e.payload;
console.log(conversation.durationSec, conversation.transcript.length);
}}
/>It fires once per conversation: after end() or the End button, when the
stream drops, or when the component unmounts.
{
"conversationId": "cm_usage_123",
"agent": { "id": "cm_agent_456", "name": "Front Desk" },
"language": "en",
"platform": "web",
"sdkVersion": "1.2.0",
"startedAt": "2026-10-12T11:40:02.000Z",
"endedAt": "2026-10-12T11:43:17.000Z",
"durationSec": 195,
"endReason": "ended",
"transcript": [
{ "role": "assistant", "text": "Welcome! How can I help you today?", "at": "2026-10-12T11:40:05.000Z" },
{ "role": "user", "text": "Two cheeseburgers please.", "at": "2026-10-12T11:40:11.000Z" }
]
}| Field | Meaning |
|---|---|
| conversationId | The conversation's FacePe id (its usage record) |
| agent | { id, name } of the agent that held the conversation (avatar is the same, kept for older apps) |
| language | The language requested for the conversation |
| platform | web, android or ios |
| sdkVersion | The SDK version that recorded it |
| startedAt, endedAt | ISO 8601 times |
| durationSec | Length in seconds |
| endReason | ended (the user or your code ended it), disconnected (the stream dropped), unmounted (the component went away) |
| transcript | Every line in order: { role: "user" \| "assistant", text, at } |
| form | For a form avatar: { title, complete, fields: [{ label, question, required, value }], documents, uploadWhere } — documents are what the avatar asked the customer to upload at the end; null for other avatars |
For an avatar that fills a form, the record also carries the form as the
conversation filled it: each question with the customer's answer (value is
null for a question that was not answered). FacePe reads the answers from the
conversation when it ends, so the record arrives a few seconds after the call.
For other avatars form is null.
"form": {
"title": "Customer Intake Form",
"complete": true,
"fields": [
{ "label": "Name", "question": "Could you please tell me your full name?", "required": true, "value": "Tony" },
{ "label": "Phone", "question": "What is the best phone number to reach you?", "required": true, "value": "9659390715" },
{ "label": "Email", "question": "May I have your email address?", "required": false, "value": "[email protected]" }
],
"documents": ["ID proof (Aadhaar / PAN)", "Address proof"],
"uploadWhere": "on our website"
}Connecting: keys on your server, a token in the browser
The secret key must never be in browser code. Your server exchanges the keys for an access token that lasts 2 hours, keeps it, and hands it to the browser:
// Your server (e.g. Express), with the keys from the FacePe dashboard in env vars.
// A token lasts 2 hours (expiresIn, in seconds): keep one and fetch a new one a
// minute before it expires, or when the SDK asks with ?refresh=1 (after a 401).
let cached: { token: string; until: number } | null = null;
app.get('/facepe-token', async (req, res) => {
if (cached && cached.until > Date.now() && req.query.refresh !== '1') {
return res.type('text').send(cached.token);
}
const r = await fetch('https://api.facepe.com/api/v1/sdk/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessKey: process.env.FACEPE_ACCESS_KEY,
secretKey: process.env.FACEPE_SECRET_KEY,
channel: 'sdk', // 'api' for direct API use
}),
});
const body = await r.json();
if (!r.ok) return res.status(r.status).json(body.error); // e.g. access switched off
cached = { token: body.data.token, until: Date.now() + (body.data.expiresIn - 60) * 1000 };
res.type('text').send(cached.token);
});apiBaseUrlis the FacePe gateway,…/api/v1/sdk. The SDK calls nothing else.- Tokens last 2 hours. The token response says so (
expiresIn, in seconds;expiresAt). Keep the token and get a new one shortly before it expires. getTokenreceives{ audience: 'app-b', forceRefresh }and returns a string or a Promise. When FacePe answers401 SESSION_EXPIRED, the SDK calls it again withforceRefresh: trueand retries once; only if that fails too doesonSessionExpiredfire.- Every request carries
X-Request-Id(correlation) andX-FacePe-SDK-Version.
Using the API directly (no SDK)
Exchange the keys with "channel": "api" (the company needs API access), then
call the gateway with Authorization: Bearer <token>:
| Request | Returns |
| --- | --- |
| POST /api/v1/sdk/token { accessKey, secretKey, channel } | { token, expiresIn, channel } |
| GET /api/v1/sdk/session | the company, the user, the user's agentIds |
| GET /api/v1/sdk/agents | the agents assigned to the user (each with its avatar's name, gender and portrait) |
| GET /api/v1/sdk/agents/:agentId | one agent (403 if not assigned) |
| POST /api/v1/sdk/agents/:agentId/sessions { language? } | the agent, and how to connect: connection ({ type: 'room', url, token } or { type: 'stream', token, greeting }), and a usageId |
| POST /api/v1/sdk/agent-sessions/:usageId/form { transcript } | for a form agent, its questions with the customer's answers ({ form }) |
| POST /api/v1/sdk/agent-sessions/:usageId/end | closes the session (usage minutes) |
| POST /api/v1/sdk/agent-sessions/:usageId/alive | the conversation is still going: send it every 30 s while it is live. If the app goes away without ending it, it is billed up to the last report, not to the 30-minute limit (404 once it has ended) |
| GET /api/v1/sdk/agents/:agentId/form | a form agent's questions and the documents it asks for ({ form }, null for other agents) |
| POST /api/v1/sdk/agents/:agentId/form/fill { transcript } | the form as the conversation so far fills it ({ form }) |
| POST /api/v1/sdk/agents/:agentId/documents multipart file, label | uploads a document the form asks for, kept with the caller's latest conversation with the agent ({ document }) |
| GET /api/v1/sdk/agents/:agentId/menu | an ordering agent's menu with prices and currency ({ menu }, null for other agents) |
| POST /api/v1/sdk/agents/:agentId/cart/fill { transcript } | the cart as the conversation so far fills it: menu items with qty and price, plus unmatched names ({ cart }) |
| POST /api/v1/sdk/agents/:agentId/orders { items: [{ menuItemId?, name?, qty }], notes?, locationId? } | places the order at menu prices and the company's tax ({ order }); 422 for items not on the menu or over the agent's limits |
| GET /api/v1/sdk/manage/options | what setting an agent up needs: avatars (open to training or not), businessTypes, fieldTypes, menus, knowledgeDocs, phoneDigitsDefault, agentLimit, agentCount |
| GET /api/v1/sdk/manage/agents | every agent of the company with its training ({ agents, agentLimit, agentCount }) |
| POST /api/v1/sdk/manage/agents/preview-prompt { name, avatarId, …training } | the instructions this training would give, without creating the agent |
| POST /api/v1/sdk/manage/agents { name, avatarId, businessType?, persona?, topics?, greeting?, menuId?, maxItemQty?, maxOrderItems?, maxOrderValue?, knowledgeDocIds?, systemPrompt? } | creates the agent ({ agent }, 201); a form agent's form goes in topics as JSON |
| GET /api/v1/sdk/manage/agents/:agentId | one agent with its training ({ agent }) |
| PATCH /api/v1/sdk/manage/agents/:agentId { name?, isEnabled?, …training, systemPrompt? } | renames, switches on/off, trains, or saves edited instructions ({ agent }) |
| POST /api/v1/sdk/manage/agents/:agentId/generate-prompt | rebuilds and saves the instructions from the training ({ agent }) |
| DELETE /api/v1/sdk/manage/agents/:agentId | deletes the agent ({ deleted: true }); its conversations stay in Logs |
| GET /api/v1/sdk/manage/agents/:agentId/users | the company's users, and which have the agent ({ users }) |
| PUT /api/v1/sdk/manage/agents/:agentId/users { userIds } | gives the agent to exactly these users ({ users }) |
The /avatars… and /avatar-sessions… paths from before agents still work
and mean the same (an avatar_id is an agent id).
Refusals: 401 for missing, invalid, expired or revoked credentials; 403
when the company's access is off, the user is disabled, or the agent is not
assigned to the user; 429 when one access key exceeds its rate limit
(SDK_RATE_LIMIT_PER_MIN, default 120 per minute).
Data: pass an id, or pass the data
Each component either shows data the host passes in, or, given only an
entityId, fetches it through the gateway (architecture §7.5):
| Component | Host passes | Or entityId = | Fetched from |
| --- | --- | --- | --- |
| FacePeTimeline | items | an order id | GET /orders/:id/timeline, the order's status history |
| FacePePicker | options | a location id | GET /locations/:id/categories, its menu categories |
| FacePeForm | initialValues | an order id | GET /orders/:id/customer; Submit saves with PUT |
Data passed by the host always wins; nothing is fetched then. While loading,
a component shows a loading state; a failed load raises onError with the
gateway's error code. Payloads are validated on arrival, and unknown fields
are ignored.
entityId can also be set once on the provider's config. It then applies
to every component, so set it per component when they show different
entities (an order and a location).
JSON Schemas of every payload ship in the package:
@facepe/widgets/schemas/FacePeTimelinePayload.schema.json, and likewise
FacePePickerOptionsPayload, FacePeCustomerPayload, FacePeTimelineItem,
FacePePickerOption, FacePeFormValues, FacePeEventMeta, FacePeError,
FacePeNavigationRequest and FacePeTelemetryEvent.
Telemetry
Pass your own sink; the SDK makes no analytics calls of its own:
<FacePeProvider config={config} telemetry={{ track: (e) => analytics.log(e) }}>track receives one record per component event (name = the event type)
and one per API call (name = 'api.request', with method, path,
status, outcome and durationMs). Every record carries the sdkVersion
and the correlationId (the same as the event's, or the request's
X-Request-Id). Event payloads are never included, since they can hold what
the user typed; error events carry only the error code. A sink that throws
is reported and ignored.
Placement: inline, page, overlay
The host decides where an experience appears; the component inside is the same.
<FacePeForm /> {/* inline: inside an existing page */}
<FacePePage><FacePeTimeline items={items} /></FacePePage> {/* full view */}
<FacePeOverlay open={open} label="Edit" onClose={() => setOpen(false)}>
<FacePeForm onCancel={() => setOpen(false)} />
</FacePeOverlay> {/* modal dialog */}
<FacePeOverlay variant="drawer" open={open} label="Details" onClose={close}>
…
</FacePeOverlay> {/* side drawer */}Titles, breadcrumbs, routing and action bars stay with the host.
Navigation
Components never change the URL. They raise navigate events with a
{ to, params?, replace? } request; wire them to your router once on the
provider (or per component):
<FacePeProvider config={config} onNavigate={(e) => router.navigate(e.payload.to)}>Events
Every on… callback receives { type, payload, meta }. meta carries
schemaVersion (1), correlationId, timestamp, sdkVersion, source
(the component) and, when the component has one, entityId. Events never
contain credentials.
Errors
Each component has its own error boundary: a failure inside it does not take
your page down. It raises onError with code RENDER_FAILED and shows the
fallback slot (or a short message). Remount it (e.g. a new key) to retry.
Imperative handles
Reach them with a ref. Every method returns a Promise<Result> and never throws.
| Component | Methods |
| --- | --- |
| FacePeForm | focus, validate, reset |
| FacePePicker | focus, reset |
| FacePeTimeline | focus, reset, scrollToSection(id) |
Submitting, selecting and cancelling are user actions; they reach you as
events (onSubmit, onSelect, onCancel), not as handle methods.
Customization
The package hard-codes no colours and no fonts. With nothing set, a component inherits the host page's text colour, font and size, has a transparent surface, and derives borders and muted text from the text colour. You choose the look, from the least to the most effort:
- Design tokens (L1) on the provider:
theme={{ colorSurface, colorSurfaceMuted, colorText, colorMuted, colorBorder, colorAccent, colorOnAccent, colorDanger, colorSuccess, fontFamily, fontSize, spacing, radius, shadow }}. For dark mode passdarkThemetoo, and setcolorScheme="light" | "dark"from your own app's setting: dark tokens override the main ones per token. The SDK never reads the system preference itself. - Variants (L2) on every component:
emphasis="subtle" | "default" | "strong",size="small" | "medium" | "large",density; per component,variant—FacePeForm:"stacked" | "inline",FacePePicker:"list" | "grid". - Slots (L3):
FacePeForm—header,actions(render function given{ submit, cancel, submitting, readOnly }),footer;FacePePicker—header,option(render function given{ option, selected, disabled }),emptyState;FacePeTimeline—item,emptyState; all —fallback. - CSS variables (L4), documented; set them on any ancestor:
| Variable | Default when unset |
| --- | --- |
| --fp-color-surface | transparent (the host's background) |
| --fp-color-surface-muted | a light tint of the text colour |
| --fp-color-text | inherited from the host |
| --fp-color-muted | the text colour, partly transparent |
| --fp-color-border | the text colour, mostly transparent |
| --fp-color-accent | the text colour (the primary action is outlined) |
| --fp-color-on-accent | inherited text colour |
| --fp-color-danger | the text colour |
| --fp-color-success | the text colour |
| --fp-font-family | inherited from the host |
| --fp-font-size | inherited from the host |
| --fp-spacing | 0.25rem (gaps and padding are multiples) |
| --fp-radius | 0.375rem (frames use 1.5×) |
| --fp-shadow | a soft shadow tinted from the text colour (emphasis="strong") |
| --fp-content-max-width | 32rem (form, picker; none in page/overlay) |
Components adapt to the width of their container (container queries), not the viewport, so they fit a sidebar or a full page alike.
npm run lint:css (also run by npm run build) fails the build if a
stylesheet hard-codes a colour or font, sets a --fp-* token, or uses an
unscoped selector.
Using the SDK in a Tailwind app
It works as is: the SDK's classes are scoped, so Tailwind and the SDK cannot clash, and its buttons keep their look under Tailwind's Preflight reset. Style it with Tailwind through the documented variables (arbitrary properties) and through slots — not by targeting the SDK's internal class names, which change with every build.
<div className="[--fp-color-accent:theme(colors.violet.600)] [--fp-color-on-accent:white] [--fp-radius:theme(borderRadius.xl)]">
<FacePeForm slots={{ header: <h3 className="text-lg font-semibold">Contact us</h3> }} />
</div>With Tailwind v4, use its CSS variables instead of theme():
[--fp-color-accent:var(--color-violet-600)].
Component styles are scoped (CSS Modules) and ship as one stylesheet,
@facepe/widgets/styles.css. Import it once; without it the components render
unstyled.
Until the package is published to a registry, a local app can install it straight from this folder:
npm install ../facepe-sdkEverything public is imported from the package root. Deeper paths such as
@facepe/widgets/dist/... are blocked by the package exports map, so
internal files can change without breaking your app.
Compatibility
| | Supported | Verified with |
| --- | --- | --- |
| React (peer dependency) | 18 and 19 | 18.3 (test suites) |
| TypeScript consumers | 5.x; bundler, node16 ESM and node16 CommonJS resolution | 5.9, all three resolution modes |
| Bundlers | any that reads exports and CSS imports | Vite 7 (SDK build), webpack 5 (tests/webpack.test.mjs) |
| Browsers | Chrome / Edge 111+, Safari 16.2+, Firefox 113+ (needs container queries, <dialog>, color-mix()) | Chrome (automated checks) |
Report an incompatibility rather than working around it in your app.
Testing
npm test # build, then every suite in tests/
npm run test:only picker # suites whose file name contains "picker" (needs a build)
npm run api:check # public API unchanged vs etc/widgets.api.md
npm run size # bundle budget (150 KB gzip) and no bundled ReactThe suites test the built package the way a host consumes it: behaviour of each component in jsdom, the TypeScript contract in three module setups, telemetry and gateway data against a fake gateway, and a webpack 5 host build.
When you change the public API on purpose, run npm run api:update and
commit etc/widgets.api.md with the change; reviewers see exactly what changed
in the contract, and the version bump (major for a breaking change) follows
from it. CI (.github/workflows/sdk.yml) runs all of the above on every pull
request, plus the gateway tests.
Releasing
Only CI publishes (npm publish refuses anywhere else):
- Bump
versioninpackage.jsonand move the[Unreleased]notes inCHANGELOG.mdunder a## [x.y.z]heading. - Merge to
main, then push the tag:git tag widgets-vx.y.z && git push origin widgets-vx.y.z. - The "SDK release" workflow re-runs every check, signs a build-provenance attestation, writes an SBOM, publishes the tarball, and creates a GitHub release with the tarball, SBOM and notes.
Support: the current and previous minor lines get security and critical fixes (N-1). Deprecated exports, slots and tokens keep working, with a warning in the changelog, for at least one minor release before removal.
Upgrading host apps
Host apps get an upgrade pull request for each release from Renovate by extending the preset in this folder:
{ "extends": ["github>Dharun4242/Facepe//facepe-sdk/renovate-host-preset"] }Pin the version (the preset does), keep the lockfile committed, and let the host's CI and visual regression run on the PR. To roll back, revert to the previous pinned version.
Adding to the public API
src/index.ts is the only public entry point and holds exports only. Add each
new public component, hook or type there by name (export { X } from ...,
export type { Y } from ...), never with export *. Anything not listed there
is internal.
Project layout
facepe-sdk/
├── src/
│ ├── index.ts public entry point: the only file consumers import from
│ ├── components/ FacePe components (Form/, Picker/, Timeline/, Placement/)
│ ├── context/ FacePeProvider and useFacePe
│ ├── types/ public TypeScript contracts
│ ├── events/ event emitter shared by components (not public)
│ ├── api/ API client, one per provider (not public)
│ ├── version.ts SDK_VERSION
│ └── globals.d.ts build-time constants and CSS Module typings
├── scripts/ build helpers (CommonJS declarations)
├── package.json package metadata, exports, peer dependencies
├── tsconfig.json strict TypeScript, declaration output
└── vite.config.ts library-mode build, React kept external