@experthireai/room
v0.2.1
Published
Embed an Expert Hire interview room in your own page.
Maintainers
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/roomQuick 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.jsis fetched from. Defaults toroomOrigin.
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:
- The iframe's
allowattribute. Set by this package. - The room's own
Permissions-Policyresponse header, which delegates to your origin. Set by us, from your allowlist. - Your page's
Permissions-Policyheader. 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.
