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

@experthireai/room

v0.2.1

Published

Embed an Expert Hire interview room in your own page.

Readme

@experthireai/room

Embed an Expert Hire interview room in your own page.

This package is a loader, not the room. It is ~40 lines: it validates your key, injects /js/v1/embed.js from the Expert Hire room host, and hands you back a mount() function. The room itself runs in a cross-origin iframe and is served by us.

That split is deliberate. The postMessage protocol between your page and the room can change without you shipping a release, and a partner who pins this package in a lockfile for two years cannot version-skew into a broken live interview.

Install

npm install @experthireai/room

Quick start

A session token is minted on your server with your secret key (ehp_sk_… for the Preparation API, ehs_sk_… for the Hiring API). Never put a secret key in the browser; the loader throws if you try.

The examples use the Preparation API. On the Hiring API the room is the same, with three changes: the keys start ehs_, the session body takes assessment_id instead of interview_id, and you pass the assessment id to mount() as interviewId.

Your backend:

// POST to the Expert Hire API base URL for your environment, with your secret key.
const res = await fetch(`${process.env.EH_API_BASE}/v1/sessions`, {
	method: "POST",
	headers: {
		Authorization: `Bearer ${process.env.EH_SECRET_KEY}`,
		"Content-Type": "application/json",
	},
	body: JSON.stringify({
		// Required, and must already be on this key's allowlist. It is the origin
		// of the page that frames the room, not the room's own origin.
		origin: "https://app.example.com",
		interview_id: interviewId,
	}),
});
const { session_token } = await res.json();

origin is the one people miss: omit it and the call returns 400 validation_failed. Send an origin that is not allowlisted and it returns 403 origin_not_allowed. Pass interview_id even though it is optional, because it is the only thing that scopes the token to one interview. The Hiring API requires assessment_id.

Your page:

import { ExpertHire } from "@experthireai/room";

const sdk = await ExpertHire.load({ publishableKey: "ehp_pk_test_…" });

const room = await sdk.mount({
	container: "#interview", // element or selector, sized by you
	sessionToken, // from your backend, above
	interviewId,
});

room.on("interview.ended", ({ reason }) => {
	console.log("ended:", reason);
	room.destroy();
});
<div id="interview" style="width: 100%; height: 640px"></div>

mount() resolves once the room has completed its handshake, or rejects after handshakeTimeoutMs (default 15000). The container element must have a real height; the iframe fills 100% of it.

Environments

ExpertHire.load({ publishableKey, environment: "sandbox" });

environment defaults to sandbox when the key contains _test_, otherwise live. It is passed through to the room; it does not select the host. One room, room.experthire.io, serves both: which backend it talks to is decided by the session token you hand it. So a _test_ key and a _live_ key are framed from the same origin, and the allowlist and headers below are the same for both.

Two escape hatches, both for local development or a self-hosted room:

  • roomOrigin - where the iframe is loaded from.
  • cdnOrigin - where /js/v1/embed.js is fetched from. Defaults to roomOrigin.
ExpertHire.load({
	publishableKey: "ehp_pk_test_…",
	roomOrigin: "http://localhost:3002",
});

Human interviews (Hiring API)

A human_interview is a call between the candidate and your interviewers, with no AI in it. Each person gets their own session: mint the candidate's with assessment_id alone, and each interviewer's with assessment_id and their interviewer_id. Mount every one the same way.

Leaving is not ending. The candidate and any interviewer can leave and rejoin. A call ends three ways: an interviewer presses "End for everyone", your server calls POST /v1/assessments/{id}/end with your secret key, or we finalize it (15 minutes after every room stops polling, at the 4 hour ceiling, or at the sandbox cap). Each of those sends interview.ended to every room.

Events

room.on(event, handler) returns an unsubscribe function.

| Event | Payload | When | | ------------------- | ------------------------------ | ------------------------------------------------------- | | ready | { interviewId, livemode } | Room booted and validated the session token | | joined | { interviewId, role? } | This participant joined the realtime session | | agent.connected | none | The AI interviewer connected | | connected | { role } | Human interview only: this participant's call connected | | left | { role } | Human interview only: this participant left; they can rejoin | | session.refreshed | none | Session token was rotated in place, no action needed | | session.expiring | none | Session is close to expiry | | interview.ended | { reason, endedBy? } | Interview finished. On a human interview, endedBy names who ended it | | error | { code, message } | Fatal for the mount, the room is torn down |

ready fires as part of the handshake that resolves mount(), so a handler registered after the await will not see it. Use the resolved handle instead.

interview.ended

reason says how the room learned the call was over. endedBy is a display name, and only a human interview someone else ended carries one.

| reason | endedBy | When | | --------------------- | --------------------------- | ------------------------------------------------------- | | ended | absent | This room pressed "End for everyone" | | ended_by_interviewer| who ended it | Another room, your server, or we ended the call | | concluded | absent | The interview was already finished when this room polled | | time_expired | absent | AI interview only: the clock ran out | | left, disconnected| absent | AI interview only: the candidate left or the connection dropped |

endedBy is the interviewer's name when an interviewer ended the call. It reads The interviewer when your server ended it with a secret key, and Expert Hire when we finalized it. Both are our strings, so key your own copy off reason if either would read wrong in your room.

Candidate speech is not emitted. Transcription, captions and local-speech events stay inside the room: forwarding them into your DOM would change who is a controller of that data. Live transcript is a future opt-in scope.

Call room.destroy() when you unmount, before navigating away or when re-mounting. It removes the iframe and the message listener. Mounting twice without destroying leaves an orphaned iframe holding a camera handle.

The two things that actually go wrong

1. Your page's Permissions-Policy does not delegate camera to the room

The room asks for camera and microphone from inside a cross-origin iframe. Three layers must all allow it, and only one of them is ours:

  1. The iframe's allow attribute. Set by this package.
  2. The room's own Permissions-Policy response header, which delegates to your origin. Set by us, from your allowlist.
  3. Your page's Permissions-Policy header. Yours.

If your site sends something like Permissions-Policy: camera=(self), the browser silently drops the delegation and the candidate sees a permission prompt that never resolves or an immediate NotAllowedError. There is no console error naming your header. Send:

Permissions-Policy: camera=(self "https://room.experthire.io"), microphone=(self "https://room.experthire.io"), display-capture=(self "https://room.experthire.io")

The same origin applies in sandbox: there is one room host, not one per environment. If you send no Permissions-Policy header at all, you are fine, the default permits delegation.

Also check any CSP frame-src on your page: it must list the room origin, or the iframe never loads.

2. Your origin is not on the key's allowlist

The room decides who may frame it from a per-key domain allowlist and sends Content-Security-Policy: frame-ancestors <your origins>. If your origin is missing, the header is frame-ancestors 'none' and the browser refuses to render the frame. It never loads, never handshakes, and mount() rejects after the handshake timeout with a message naming the room origin.

Symptoms: a blank iframe plus a browser console line about refusing to frame the document. This is not a bug in your code. Add the origin with your secret key for that environment:

curl -X POST $EH_API_BASE/v1/domains \
  -H "Authorization: Bearer $EH_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin": "https://app.example.com"}'

Scheme and port are part of the origin: https://app.example.com, https://www.example.com and http://localhost:3000 are three different entries. GET /v1/domains lists what is registered.

Allowlist changes take up to a minute to take effect.

TypeScript

Types ship with the package. RoomEventType, MountOptions, RoomHandle, LoadOptions and LoadedSdk are all exported.

Browser support

Modern evergreen browsers, ES2020. There is no server-side rendering: load() throws if there is no window, so call it from an effect or event handler, not during render.