npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 install

This also installs React as a dev dependency, used only for type checking and building the SDK itself.

Build

npm run build

This runs these steps, and stops at the first failure:

  1. lint-css checks the stylesheets against the styling contract.
  2. vite build compiles src/ in library mode to JavaScript.
  3. tsc generates the TypeScript declaration files (and emit-cjs-types their CommonJS copies).
  4. emit-schemas generates the JSON Schemas of the payload types.

Type check without building:

npm run typecheck

Build 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? → allowed

Revoking 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 onCheckout with the items, for your own backend. With placeOrder, checkout places the order with FacePe (the agent's menu prices and the company's tax), fires onOrderPlaced with 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 its options. 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 with setFormAnswer is 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, and checkbox (Checkboxes) takes one or more of its options, 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: checkFormAnswer applies it.
  • date (Date & Time) is YYYY-MM-DD, or YYYY-MM-DD HH:MM when a time is given.
  • dob (Date of Birth) is a real date in the past, at most 120 years ago, and at least minAge years 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) => ...} or onValuesChange={(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);
});
  • apiBaseUrl is 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.
  • getToken receives { audience: 'app-b', forceRefresh } and returns a string or a Promise. When FacePe answers 401 SESSION_EXPIRED, the SDK calls it again with forceRefresh: true and retries once; only if that fails too does onSessionExpired fire.
  • Every request carries X-Request-Id (correlation) and X-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:

  1. Design tokens (L1) on the provider: theme={{ colorSurface, colorSurfaceMuted, colorText, colorMuted, colorBorder, colorAccent, colorOnAccent, colorDanger, colorSuccess, fontFamily, fontSize, spacing, radius, shadow }}. For dark mode pass darkTheme too, and set colorScheme="light" | "dark" from your own app's setting: dark tokens override the main ones per token. The SDK never reads the system preference itself.
  2. Variants (L2) on every component: emphasis="subtle" | "default" | "strong", size="small" | "medium" | "large", density; per component, variant — FacePeForm: "stacked" | "inline", FacePePicker: "list" | "grid".
  3. 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.
  4. 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-sdk

Everything 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 React

The 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):

  1. Bump version in package.json and move the [Unreleased] notes in CHANGELOG.md under a ## [x.y.z] heading.
  2. Merge to main, then push the tag: git tag widgets-vx.y.z && git push origin widgets-vx.y.z.
  3. 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