@prvt/integration-sdk
v0.8.0
Published
TypeScript SDK for building visible Node.js integrations for prvt rooms.
Maintainers
Readme
@prvt/integration-sdk
TypeScript SDK for building visible Node.js integrations for a self-hosted prvt room.
The SDK exchanges a room-scoped integration key for a short-lived participant token and can connect the integration through the official LiveKit Node SDK. It does not persist credentials, retry mutations, or hide integration participants.
Integration manifest
Approved providers use the same public manifest shape everywhere they appear:
import type { IntegrationManifest } from "@prvt/integration-sdk";
const manifest: IntegrationManifest = {
title: "Music bot",
description: "Play tracks from YouTube and SoundCloud, controlled from room chat.",
imageUrl: "https://bot.example.com/icon.png",
websiteUrl: "https://bot.example.com/",
permissions: {
subscribeMedia: false,
publishMicrophone: true,
publishData: true,
publishTranscription: false,
showAsParticipant: true,
},
};The server-side approved manifest is authoritative: the browser never trusts a
provider-supplied permission claim. imageUrl may be null; non-null images
and websites must use HTTPS without embedded credentials. The permission
booleans are the exact grants used when a room moderator creates the provider's
one-time integration key.
Install
npm install @prvt/integration-sdk @livekit/rtc-nodeNode.js 18 or newer is required. @livekit/rtc-node is a peer dependency so
the application controls the native realtime runtime version.
Coding agents can start from the reusable
AGENT_PROMPT.md, which summarizes the supported workflow,
security boundaries, permissions, and completion checks.
Create an integration key
Join a room in the browser that created it, open Room tools, and choose
Copy integrations API token. The Personal integration key is shown only
once. Move it directly into the Node application's secret manager before
closing the dialog.
Never put the integration key in a URL, log, source file, or browser storage.
Connect
import { AudioStream, RoomEvent, VideoStream } from "@livekit/rtc-node";
import { createPrvtIntegration } from "@prvt/integration-sdk";
const integrationKey = process.env.PRVT_INTEGRATION_KEY;
if (!integrationKey) throw new Error("PRVT_INTEGRATION_KEY is required");
const integration = createPrvtIntegration({
baseUrl: "https://call.example.com",
integrationKey,
displayName: "Transcript bot",
});
const session = await integration.connect({
roomOptions: {
autoSubscribe: true,
dynacast: false,
},
});
session.room.on(RoomEvent.ParticipantConnected, (participant) => {
console.log(`${participant.name ?? participant.identity} joined`);
});
const stopAudio = session.onAudioTrack(async (track, publication, participant) => {
const stream = new AudioStream(track, { sampleRate: 48_000, numChannels: 1 });
const reader = stream.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
// Send value.data (Int16 PCM) to your audio pipeline.
console.log("audio", participant.identity, publication.source, value.data.length);
}
} finally {
await reader.cancel().catch(() => undefined);
}
});
const stopVideo = session.onVideoTrack(async (track, publication, participant) => {
const stream = new VideoStream(track);
const reader = stream.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
// Process value.frame using your video pipeline.
console.log("video", participant.identity, publication.source, value.frame.width, value.frame.height);
}
} finally {
await reader.cancel().catch(() => undefined);
}
});
process.once("SIGINT", async () => {
stopAudio();
stopVideo();
await session.disconnect();
process.exit(0);
});session.room is the official @livekit/rtc-node Room. Use its track,
participant, text-stream, and data APIs directly. The integration's stable
identity, expiry, and effective permissions are also available on the session.
onAudioTrack() and onVideoTrack() are permission-gated convenience listeners
for subscribed remote tracks. They include the LiveKit publication and
participant, replay tracks that were already subscribed during connection, and
return an unsubscribe function. AudioStream yields PCM AudioFrame values;
VideoStream yields VideoFrame events. Cancel each stream reader when the
pipeline stops. Calling either listener without subscribeMedia throws
PrvtMediaSubscriptionUnavailableError. prvt-specific profile, pin, and
reaction helpers are described below.
Exchange without connecting
Use exchangeToken() when the application needs to configure the LiveKit room
itself:
const token = await integration.exchangeToken();
// token.participantToken short-lived bearer JWT
// token.serverUrl public LiveKit WebSocket URL
// token.participantIdentity
// token.expiresAt
// token.permissionsThe participant JWT is valid for five minutes and exactly one managed room.
Fetch a fresh token before a full reconnect instead of persisting it as a
replacement for the integration key. displayName is optional; when omitted,
the name chosen when the integration key was created is used. When supplied,
the server normalizes and validates it for that exchange without changing the
owner-facing integration record. The effective name is available on the token
and on session.displayName.
Reconnect after disconnect or moderator resume
connect() performs one token exchange and one LiveKit connection; the SDK does
not retry it automatically. A long-running integration should wait for the
LiveKit Disconnected event, then call connect() again so the server issues a
fresh participant JWT. This also handles a moderator resuming a paused
integration: the next exchange succeeds, but only after the moderator has
cleared the pause.
import { RoomEvent } from "@livekit/rtc-node";
import {
PrvtApiError,
PrvtConnectionError,
PrvtNetworkError,
} from "@prvt/integration-sdk";
const wait = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const waitForDisconnect = (session: Awaited<ReturnType<typeof integration.connect>>) => (
new Promise<void>((resolve) => {
const onDisconnected = () => {
session.room.off(RoomEvent.Disconnected, onDisconnected);
resolve();
};
session.room.on(RoomEvent.Disconnected, onDisconnected);
})
);
let stopping = false;
let activeSession: Awaited<ReturnType<typeof integration.connect>> | undefined;
const stop = () => {
stopping = true;
void activeSession?.disconnect();
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
while (!stopping) {
try {
activeSession = await integration.connect({ roomOptions: { autoSubscribe: true } });
// Register listeners and reapply live-only profile state here.
await waitForDisconnect(activeSession);
} catch (error) {
if (error instanceof PrvtApiError && error.code === "INTEGRATION_PAUSED") {
await wait(5_000);
continue;
}
if (error instanceof PrvtApiError && ["INTEGRATION_REVOKED", "ROOM_CLOSED", "UNAUTHORIZED"].includes(error.code)) {
throw error;
}
if (!(error instanceof PrvtConnectionError) && !(error instanceof PrvtNetworkError)) throw error;
await wait(5_000);
} finally {
await activeSession?.disconnect().catch(() => undefined);
activeSession = undefined;
}
}Treat INTEGRATION_PAUSED as an expected operator-controlled state rather than
an invalid key. Use bounded backoff so a paused integration does not busy-loop;
once the moderator resumes it, the next fresh exchange reconnects it.
Register an integration-owned V2 lifecycle webhook
An integration can own one active V2 webhook subscription. Generate a separate
webhook key and secret, store the secret encrypted on the receiving server, and
only then register the endpoint. generatePrvtWebhookCredentials() returns the
raw secret once to your process. The registration response deliberately omits
it, so losing the secret requires rotation with the current revision.
import {
createPrvtIntegration,
generatePrvtWebhookCredentials,
} from "@prvt/integration-sdk";
const integration = createPrvtIntegration({
baseUrl: "https://call.example.com",
integrationKey: process.env.PRVT_INTEGRATION_KEY!,
});
const credentials = generatePrvtWebhookCredentials();
// This must be encrypted server storage. It must be available before PUT,
// because prvt challenges the endpoint before activating the subscription.
await encryptedWebhookSecrets.store(credentials.keyId, credentials.secret);
const subscription = await integration.registerWebhookSubscription({
endpointUrl: "https://bot.example.com/prvt-webhook",
events: [
"integration.paused",
"integration.resumed",
"integration.revoked",
"integration.permissions_changed",
"room.closed",
],
credentials,
});
// subscription contains metadata and a revision, never the raw secret.
await webhookMetadata.store(subscription);The generated secret uses the prvt_webhook_v1. credential-format prefix.
That credential version is independent of the V2 event and v2= signature
protocol. Fixed-provider V1 secrets have the same textual format, but they are
separate credentials and must never be reused or copied between configurations.
Registration sends the integration key only as the private Bearer credential.
The webhook secret appears only in the HTTPS JSON body of registration or
rotation. getWebhookSubscription(), deleteWebhookSubscription(), normal
token/profile calls, metadata responses, and webhook deliveries never carry
the raw secret. Do not log either credential or keep the webhook secret in
plaintext storage.
Rotate credentials with an optimistic revision check. Keep both encrypted keys available while an older signed delivery may still be in flight:
const current = await integration.getWebhookSubscription();
const nextCredentials = generatePrvtWebhookCredentials();
await encryptedWebhookSecrets.store(nextCredentials.keyId, nextCredentials.secret);
const rotated = await integration.registerWebhookSubscription({
endpointUrl: current.endpointUrl,
events: current.events,
credentials: nextCredentials,
expectedRevision: current.revision,
});
await webhookMetadata.store(rotated);Keep the previous secret available for at least 24 hours so already-enqueued deliveries pinned to the older revision can still verify. To disable future delivery entirely, fetch the current revision and delete it explicitly:
const current = await integration.getWebhookSubscription();
await integration.deleteWebhookSubscription({ expectedRevision: current.revision });The receiving endpoint must preserve the exact raw bytes and select the secret by the signed key ID. A V2 verification challenge requires an exact proof response:
import {
PrvtWebhookVerificationError,
createPrvtWebhookV2ChallengeResponse,
verifyPrvtWebhookV2,
type PrvtWebhookReplayAdapter,
} from "@prvt/integration-sdk";
const replay: PrvtWebhookReplayAdapter = {
async claim(eventId, expiresAt) {
return webhookEvents.claimOnce(eventId, expiresAt);
},
};
export async function handleWebhook(request: Request): Promise<Response> {
const rawBody = await request.arrayBuffer();
try {
let selectedSecret: string | undefined;
const event = await verifyPrvtWebhookV2(
request.headers,
rawBody,
async (keyId) => {
selectedSecret = await encryptedWebhookSecrets.load(keyId);
return selectedSecret;
},
{ replay },
);
if (event.type === "verification.challenge") {
if (!selectedSecret) return new Response("Invalid webhook", { status: 400 });
return Response.json(
await createPrvtWebhookV2ChallengeResponse(event, selectedSecret),
);
}
// Persist both IDs from a verified challenge. For later lifecycle events,
// pass them as verifier options to bind this receiver before replay claim.
await wakeIntegrationReconciler(event.integration.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof PrvtWebhookVerificationError) {
if (error.code === "replay") return new Response(null, { status: 204 });
return new Response("Invalid webhook", { status: 400 });
}
throw error;
}
}V2 events have no provider field. Verification requires the canonical key ID,
the v2= raw-body HMAC, an exact challenge or lifecycle schema, and a timestamp
within five minutes by default. Use an atomic replay adapter with the default
24-hour retention. Lifecycle deliveries are unordered, at-least-once advisory
wake-ups: coalesce them and reconcile with a fresh token exchange rather than
treating event status or permissions as current authority.
Receive deployment-managed V1 lifecycle webhooks
An approved provider can receive deployment-managed V1 signed lifecycle events for integrations that
were created from its manifest. Verify the exact raw request body before JSON
parsing. Body parsers that decode and then reserialize JSON change the signed
bytes and must not run before verifyPrvtWebhook().
import {
PrvtWebhookVerificationError,
verifyPrvtWebhook,
type PrvtWebhookReplayAdapter,
} from "@prvt/integration-sdk";
const webhookSecret = process.env.PRVT_WEBHOOK_SECRET;
if (!webhookSecret) throw new Error("PRVT_WEBHOOK_SECRET is required");
// Back this with an atomic INSERT-if-absent in shared, durable storage.
const replay: PrvtWebhookReplayAdapter = {
async claim(eventId, expiresAt) {
return webhookEvents.claimOnce(eventId, expiresAt);
},
};
export async function handleWebhook(request: Request): Promise<Response> {
const rawBody = await request.arrayBuffer();
try {
const event = await verifyPrvtWebhook(
request.headers,
rawBody,
webhookSecret,
{
keyId: "whk_primary",
providerId: "music-bot",
replay,
},
);
// Every event type follows the same path. This durable, coalescing wake-up
// exchanges the integration key again; the fresh API result decides whether
// the integration is active, paused, revoked, or belongs to a closed room.
await wakeIntegrationReconciler(event.integration.id);
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof PrvtWebhookVerificationError) {
// A valid duplicate has already caused (or queued) reconciliation. Ack it
// without running the handler's effects again.
if (error.code === "replay") return new Response(null, { status: 204 });
return new Response("Invalid webhook", { status: 400 });
}
throw error;
}
}The V1 verifyPrvtWebhook() API and event schema remain unchanged.
verifyPrvtWebhook() accepts Headers or a case-insensitive header record and
a raw string, Uint8Array, or ArrayBuffer. It requires all four
prvt-webhook-id, prvt-webhook-timestamp, prvt-webhook-key-id, and
prvt-webhook-signature headers, checks the v1= HMAC-SHA256 signature with
Web Crypto, enforces a five-minute timestamp tolerance by default, rejects
bodies over 16 KiB, and then parses the exact V1 event shape. Pass the expected
keyId during secret rotation, the endpoint's expected approved providerId,
and an atomic replay adapter in production. The provider binding is checked
before replay state is claimed. Replay claims expire 24 hours after receipt by
default, independently of the five-minute signature tolerance, because a sender
retry uses the same event ID with a fresh delivery timestamp.
replayRetentionSeconds accepts positive whole seconds up to 30 days.
Verification errors expose only the sanitized signature,
timestamp, replay, or protocol code.
Webhook delivery is unordered and at least once. Treat every event as a prompt
to reconcile, not permission to reuse an old participant JWT or apply the
event's possibly stale status directly. A reconciler should coalesce wake-ups,
exchange the long-lived integration key again, and treat the fresh API result
and connected session permissions as authoritative. A handler that keeps an
ordered event cursor may ignore an event whose createdAt is older than the
last applied mutation; reconciling current API state is safer when ordering is
uncertain. A valid duplicate should normally receive a successful response
without repeating side effects. Keep bounded reconnect backoff as a fallback
for delayed or missed webhook delivery.
Permissions
The server returns the effective permissions with every exchange:
subscribeMediaallows room-wide media subscription, including both audio and video.publishMicrophoneallows publication restricted to microphone-source tracks.publishDataallows room-wide data publication. Topics such asprvt.chatroute messages but are not authorization boundaries.publishTranscriptionallows the integration to stream bounded subtitle segments attached to a participant and audio track. It does not grant the chat helpers.showAsParticipantcontrols whether the web client renders the integration as a participant with a profile card. It does not change LiveKit membership, the integration count, or the integration's permitted realtime streams.
PRVT_CHAT_TOPIC exports the exact prvt.chat topic name.
Participant subtitles
Use createTranscriptionStream() for live transcription or translation. The
stream is broadcast to the room on prvt.transcription; the SDK adds the
target participant, audio track, segment ID, language, and partial/final state
as validated attributes:
const subtitle = await session.createTranscriptionStream({
participantIdentity: "ada-lovelace-12345678",
trackSid: "TR_audio_123",
segmentId: "segment_42",
language: "ru",
final: false,
});
await subtitle.write("Привет, ");
await subtitle.write("как дела?");
await subtitle.close();Create another stream with the same segmentId to replace a partial segment
with its final text. The browser displays the newest active segment for each
target participant as ephemeral subtitles. This method requires
publishTranscription; missing permission throws
PrvtTranscriptionPublishingUnavailableError.
When receiving media, pass roomOptions.autoSubscribe: true to connect().
Use onAudioTrack() for microphone audio and onVideoTrack() for camera or
screen-share video; inspect the supplied publication's source when the
pipeline needs to distinguish them. A track callback may run more than once
over a reconnect, so media pipelines should be idempotent and release their
stream readers when they finish.
Message language context
Human messages from the prvt web app carry the sender's selected UI language
(en or ru) in the text-stream attribute prvt.chat.language. Read it from
the LiveKit stream metadata:
import { parseChatMessageLanguage } from "@prvt/integration-sdk";
// Inside your LiveKit text-stream handler:
const language = parseChatMessageLanguage(reader.info.attributes);PRVT_CHAT_LANGUAGE_ATTRIBUTE also exports the attribute name. The helper
returns null for missing or unknown values, including messages from older
clients. Choose your integration's fallback when no preference is available.
The value describes the sender's UI at send time, not the detected language of
the text. It can guide localized replies; it does not translate messages or
change a participant's interface automatically.
Markdown and callback keyboards
Integrations can send bounded Markdown, native callback buttons, HTTPS link buttons, and embedded web-app buttons. Callback buttons may include an optional toast value that is returned with the button event and shown to the clicking participant as a temporary chat alert. Raw HTML, images, unsafe links, oversized labels, and oversized keyboards are rejected by the SDK or ignored by the web client.
const message = await session.sendMessage("**Choose a track**", {
format: "markdown",
keyboard: {
v: 1,
rows: [[
{ id: "play", label: "Play", toast: "play now", style: "primary" },
{ id: "pause", label: "Pause" },
{ app: "https://music.example/queue", label: "Queue" },
]],
},
});
const stopListening = session.onChatCallback(async (callback) => {
if (callback.buttonId !== "play") {
await session.acknowledgeChatCallback(callback, { status: "rejected", text: "Unavailable", alert: true });
return;
}
console.log(callback.toast);
await session.acknowledgeChatCallback(callback, { status: "accepted", text: "Starting" });
await session.sendMessage("Started playing.");
});The browser sends callback packets on PRVT_CHAT_CALLBACK_TOPIC directly to
the integration that authored the message. The SDK derives senderIdentity
from LiveKit's authenticated DataReceived event. Acknowledgements use
PRVT_CHAT_CALLBACK_ACK_TOPIC and are targeted to that participant. Call the
unsubscribe function during shutdown. The transport is realtime and
best-effort, so actions should be idempotent and no callback should be treated
as persisted or replayable.
Embedded web apps
Use the app button field for a path-only HTTPS app URL owned by the connected
integration:
const message = await session.sendMessage("Choose a track", {
keyboard: { v: 1, rows: [[{ app: "https://music.example/queue", label: "Queue" }]] },
});
const stopListening = session.onWebAppRequest(async (request) => {
if (request.action !== "queue.add") return;
await queue.add(request.payload);
await session.respondToWebAppRequest(request, { ok: true, result: { queued: true } });
});The browser app uses the separate @prvt/mini-app-sdk package:
import { createPrvtMiniApp } from "@prvt/mini-app-sdk";
const app = createPrvtMiniApp({ hostOrigin: "https://call.example.com" });
await app.ready();
await app.request("queue.add", { trackId: "track_123" });The app receives no room credentials or integration key. senderIdentity is
derived from LiveKit by the Node SDK, not accepted from the browser payload.
App actions are bounded, targeted, realtime-only, and should be validated and
idempotent by the integration.
Editing chat messages
editMessage() replaces an integration-authored chat message, including its
Markdown format and keyboard, for up to 60 minutes after sendMessage()
returns. Omit keyboard to preserve it, provide a new keyboard to replace it,
or pass keyboard: null to remove it. The SDK remembers sent message IDs for
the current live session and publishes edits on PRVT_CHAT_EDITS_TOPIC:
const message = await session.sendMessage("Preparing the results…");
await session.editMessage(message.id, "Results are ready", {
format: "markdown",
keyboard: {
v: 1,
rows: [[{ id: "details", label: "Show details" }]],
},
});Edits are realtime-only and update the pinned preview when that message is pinned. A new SDK connection cannot edit messages sent by an earlier session.
Profile picture and status
Update the connected bot's live profile attributes through the authenticated prvt API. A status can hold changing context such as a music bot's current song:
await session.updateProfile({
profilePicture: "data:image/webp;base64,<base64-webp-bytes>",
statusMessage: "Playing Midnight City",
websiteUrl: "https://music.example.com/",
webAppUrl: "https://music.example.com/room-app",
});
await session.updateProfile({ statusMessage: "Playing Intro" });
await session.updateProfile({ statusMessage: null });
await session.updateProfile({ websiteUrl: null });
await session.updateProfile({ webAppUrl: null });Pictures must be PNG, JPEG, or WebP data URLs no longer than 12,000 characters.
Remote URLs and SVG are rejected so participant browsers never contact a
bot-selected image host. Status messages are normalized to one line and limited to
128 characters. null clears a field. Updates affect only the active LiveKit
participant and are not stored in the managed-room database, so reapply the
profile after reconnecting. Updating while disconnected returns
INTEGRATION_NOT_CONNECTED. The API accepts 60 profile updates per integration
per minute and returns the ordinary RATE_LIMITED error plus Retry-After
after that bound.
The optional websiteUrl must be HTTPS and contain no embedded credentials.
It links the integration's displayed name in chat and on its media card. The
optional webAppUrl must be HTTPS, contain no embedded credentials, query, or
fragment, and be no longer than 512 characters. It adds a small room-app button
before the integration's name on its participant card and opens the embedded
room-app modal used by chat app buttons. Omitting either field preserves its
current value; null clears it. Both fields are live-only, like the other
profile fields, so reapply them after reconnecting. A profile website or app
does not imply marketplace approval. Never put integration keys, room
credentials, or participant tokens in either URL.
Chat pins
With publishData, an integration can pin and unpin a message by the LiveKit
chat message ID:
const message = await session.sendMessage("Voting closes in two minutes");
await session.pinMessage(message.id);
await session.unpinMessage(message.id);sendMessage() sends the normalized text on PRVT_CHAT_TOPIC and returns its
stream ID. Pins use versioned reliable packets on PRVT_CHAT_PINS_TOPIC. An unpin clears
only the named current pin, so a delayed unpin cannot remove a newer one. Pins
are ephemeral like chat: there is no persistence, replay, or late-join history.
Bot reactions and rate limit
With publishData, send any emoji in PRVT_REACTION_EMOJI:
await session.sendReaction("👏");The catalog includes 😶 and 👎. It retains 🎉 for version-1 packet compatibility with existing integrations, though 🎉 is no longer offered in the human picker.
Each SDK session accepts at most three reactions per rolling ten seconds. A
fourth throws PrvtReactionRateLimitError and exposes retryAfterMs. The web
client enforces the same per-integration display limit even when a bot publishes
directly through LiveKit. Reactions use lossy packets on PRVT_REACTIONS_TOPIC.
Personal integrations created from Room tools currently receive all five permissions. The owner API can create integrations with selected permissions.
Errors
import {
PrvtApiError,
PrvtConnectionError,
PrvtDataPublishingUnavailableError,
PrvtTranscriptionPublishingUnavailableError,
PrvtNetworkError,
PrvtProtocolError,
PrvtReactionRateLimitError,
} from "@prvt/integration-sdk";
try {
await integration.connect();
} catch (error) {
if (error instanceof PrvtApiError) {
console.error(error.code, error.status, error.retryAfterSeconds);
} else if (error instanceof PrvtNetworkError) {
console.error("The prvt API is unreachable");
} else if (error instanceof PrvtProtocolError) {
console.error("The API response did not match the SDK contract");
} else if (error instanceof PrvtConnectionError) {
console.error("LiveKit connection failed");
} else if (error instanceof PrvtReactionRateLimitError) {
console.error(`Retry the reaction in ${error.retryAfterMs} ms`);
} else if (error instanceof PrvtDataPublishingUnavailableError) {
console.error("This integration cannot publish room data");
}
}The SDK does not include integration keys, participant JWTs, request headers,
or response bodies in its own error messages. It does not retry automatically.
Respect retryAfterSeconds for RATE_LIMITED; use bounded backoff for
temporary STORAGE_UNAVAILABLE or LIVEKIT_UNAVAILABLE failures, and for the
operator-controlled INTEGRATION_PAUSED state; require operator action for
revoked, closed, or unauthorized credentials.
Visibility and lifecycle
Integrations are ordinary LiveKit room participants. The server-signed
showAsParticipant permission controls whether prvt renders their participant
card; it does not change room membership or the separate integration count.
Visible integrations use server-signed metadata to drive their badge, roster
entry, join announcement, and chat attribution. A
new connection with the same stable integration identity replaces the older
connection.
Revoking an integration blocks future token exchanges and attempts to remove the connected bot. An already issued JWT cannot be invalidated and may reconnect for the remainder of its five-minute lifetime. Always disconnect the session cleanly during shutdown.
This package intentionally does not wrap the owner API or browser moderator flow. It is the runtime SDK for a Node.js integration after an integration key has been created.
