feat: Lyra App (#27370)

* feat: lyra conference app

* fix: lyra -> lyra meeting

* api url in env example

* feat: lyra oauth

* remove setup page

* fix: revision on feb 25 review

* production api url

* fix: final suggestions implementation

* yarn.lock revert
This commit is contained in:
Henry Kwon
2026-03-12 07:10:22 +00:00
committed by GitHub
parent 7ff2eaf293
commit 7c8f17ccc7
22 changed files with 388 additions and 3 deletions
+1 -1
View File
@@ -520,4 +520,4 @@ LINKEDIN_ADS_ENABLED=1 # To enable LinkedIn Ads tracking (li_fat_id)
B2_APPLICATION_KEY_ID=
B2_APPLICATION_KEY=
B2_BUCKET_ID=
B2_BUCKET_NAME=
B2_BUCKET_NAME=
+1 -2
View File
@@ -1,6 +1,5 @@
import dynamic from "next/dynamic";
import { DynamicComponent } from "@calcom/app-store/_components/DynamicComponent";
import dynamic from "next/dynamic";
export const AppSetupMap = {
alby: dynamic(() => import("@calcom/web/components/apps/alby/Setup")),
@@ -23,6 +23,7 @@ import { appKeysSchema as intercom_zod_ts } from "./intercom/zod";
import { appKeysSchema as jelly_zod_ts } from "./jelly/zod";
import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appKeysSchema as lyra_zod_ts } from "./lyra/zod";
import { appKeysSchema as make_zod_ts } from "./make/zod";
import { appKeysSchema as matomo_zod_ts } from "./matomo/zod";
import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod";
@@ -74,6 +75,7 @@ export const appKeysSchemas = {
jelly: jelly_zod_ts,
jitsivideo: jitsivideo_zod_ts,
larkcalendar: larkcalendar_zod_ts,
lyra: lyra_zod_ts,
make: make_zod_ts,
matomo: matomo_zod_ts,
metapixel: metapixel_zod_ts,
@@ -55,6 +55,7 @@ import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata";
import lindy_config_json from "./lindy/config.json";
import linear_config_json from "./linear/config.json";
import lyra_config_json from "./lyra/config.json";
import make_config_json from "./make/config.json";
import matomo_config_json from "./matomo/config.json";
import metapixel_config_json from "./metapixel/config.json";
@@ -166,6 +167,7 @@ export const appStoreMetadata = {
larkcalendar: larkcalendar__metadata_ts,
lindy: lindy_config_json,
linear: linear_config_json,
lyra: lyra_config_json,
make: make_config_json,
matomo: matomo_config_json,
metapixel: metapixel_config_json,
@@ -23,6 +23,7 @@ import { appDataSchema as intercom_zod_ts } from "./intercom/zod";
import { appDataSchema as jelly_zod_ts } from "./jelly/zod";
import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod";
import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod";
import { appDataSchema as lyra_zod_ts } from "./lyra/zod";
import { appDataSchema as make_zod_ts } from "./make/zod";
import { appDataSchema as matomo_zod_ts } from "./matomo/zod";
import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod";
@@ -74,6 +75,7 @@ export const appDataSchemas = {
jelly: jelly_zod_ts,
jitsivideo: jitsivideo_zod_ts,
larkcalendar: larkcalendar_zod_ts,
lyra: lyra_zod_ts,
make: make_zod_ts,
matomo: matomo_zod_ts,
metapixel: metapixel_zod_ts,
@@ -40,6 +40,7 @@ export const apiHandlers = {
jitsivideo: import("./jitsivideo/api"),
larkcalendar: import("./larkcalendar/api"),
linear: import("./linear/api"),
lyra: import("./lyra/api"),
make: import("./make/api"),
matomo: import("./matomo/api"),
metapixel: import("./metapixel/api"),
@@ -20,6 +20,7 @@ import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadat
import insihts_config_json from "./insihts/config.json";
import jelly_config_json from "./jelly/config.json";
import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
import lyra_config_json from "./lyra/config.json";
import matomo_config_json from "./matomo/config.json";
import metapixel_config_json from "./metapixel/config.json";
import mirotalk_config_json from "./mirotalk/config.json";
@@ -65,6 +66,7 @@ export const appStoreMetadata = {
insihts: insihts_config_json,
jelly: jelly_config_json,
jitsivideo: jitsivideo__metadata_ts,
lyra: lyra_config_json,
matomo: matomo_config_json,
metapixel: metapixel_config_json,
mirotalk: mirotalk_config_json,
+23
View File
@@ -0,0 +1,23 @@
The meeting platform built for pros. One-click join, no downloads, and no visible bots watching your guests. While your competition switches between five tools, everything happens in Lyra.
## Your Live AI Teammate
Ask questions during the call and generate proposals before you hang up. No more copying transcripts into other AI platforms or waiting for recordings to process.
## Zero-Touch Admin
Follow-ups write themselves and next steps are captured instantly. Every meeting becomes searchable so you stop wasting your week on manual data entry.
## Why Teams Switch to Lyra
- **Stop wasting time on admin** — Proposals, next steps, and follow-ups are ready before you hang up
- **Faster rep ramp-up** — New reps lean on Lyra for live product questions and context, selling faster without months of training
- **Deals don't disappear** — Every conversation lives in one place. Ask Lyra to catch you up on deal history instantly
- **Accurate forecasting** — Deal reality is captured on every call. Know exactly where deals stand without guessing
## How It Works
1. Install Lyra from the Cal.com App Store
2. Sign in with your Lyra account to authorize Cal.com
3. Select Lyra as your conferencing option for event types
4. When bookings are made, Lyra meeting links are automatically generated
+37
View File
@@ -0,0 +1,37 @@
import { stringify } from "node:querystring";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import type { NextApiRequest } from "next";
import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState";
import { getLyraAppKeys, LYRA_API_URL } from "../lib";
async function handler(req: NextApiRequest) {
await prisma.user.findFirstOrThrow({
where: {
id: req.session?.user?.id,
},
select: {
id: true,
},
});
const { client_id } = await getLyraAppKeys();
const state = encodeOAuthState(req);
const params = {
response_type: "code",
client_id,
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/lyra/callback`,
scope: "meeting.create",
state,
};
const query = stringify(params);
const url = `${LYRA_API_URL}/oauth/authorize?${query}`;
return { url };
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
+80
View File
@@ -0,0 +1,80 @@
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import type { NextApiRequest, NextApiResponse } from "next";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState";
import { getLyraAppKeys, LYRA_API_URL } from "../lib";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const state = decodeOAuthState(req);
// Handle user denial or other OAuth errors gracefully
if (req.query.error) {
const errorReturnTo = getSafeRedirectUrl(state?.onErrorReturnTo) ?? "/apps/installed/conferencing";
res.redirect(errorReturnTo);
return;
}
const userId = req.session?.user.id;
if (!userId) {
return res.status(404).json({ message: "No user found" });
}
const { code } = req.query;
const { client_id, client_secret } = await getLyraAppKeys();
const redirectUri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/lyra/callback`;
// Exchange authorization code for access token
const result = await fetch(`${LYRA_API_URL}/api/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
code,
client_id,
client_secret,
redirect_uri: redirectUri,
}),
});
if (result.status !== 200) {
let errorMessage = "Something went wrong with the Lyra OAuth flow";
try {
const responseBody = await result.json();
errorMessage = responseBody.error || errorMessage;
} catch (_e) {
errorMessage = await result.clone().text();
}
res.status(400).json({ message: errorMessage });
return;
}
const responseBody = await result.json();
if (responseBody.error) {
res.status(400).json({ message: responseBody.error });
return;
}
// Convert expires_in (seconds) to expiry_date (absolute ms timestamp)
// This follows Cal.com's universal OAuth token schema
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
delete responseBody.expires_in;
// Remove any existing lyra_video credentials for this user to avoid duplicates
await prisma.credential.deleteMany({
where: { type: "lyra_video", userId, appId: "lyra" },
});
await createOAuthAppCredential({ appId: "lyra", type: "lyra_video" }, responseBody, req);
res.redirect(
getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "lyra" })
);
}
+2
View File
@@ -0,0 +1,2 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
+22
View File
@@ -0,0 +1,22 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Lyra",
"title": "Lyra",
"slug": "lyra",
"type": "lyra_video",
"logo": "icon.svg",
"url": "https://lyra.so/",
"variant": "conferencing",
"categories": ["conferencing"],
"publisher": "Lyra",
"email": "[email protected]",
"description": "The meeting platform built for pros.",
"isOAuth": true,
"appData": {
"location": {
"linkType": "dynamic",
"type": "integrations:lyra",
"label": "Lyra Meeting"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * as lib from "./lib";
@@ -0,0 +1,164 @@
import {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter";
import { invalidateCredential } from "../../_utils/invalidateCredential";
import { OAuthManager } from "../../_utils/oauth/OAuthManager";
import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential";
import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired";
import { LYRA_API_URL } from "./constants";
import { getLyraAppKeys } from "./getLyraAppKeys";
const log = logger.getSubLogger({ prefix: ["[lyra]"] });
const LyraVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => {
const tokenResponse = getTokenObjectFromCredential(credential);
const fetchLyraApi = async (endpoint: string, options?: RequestInit) => {
const auth = new OAuthManager({
credentialSyncVariables: {
APP_CREDENTIAL_SHARING_ENABLED,
CREDENTIAL_SYNC_ENDPOINT,
CREDENTIAL_SYNC_SECRET,
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
},
resourceOwner: {
type: "user",
id: credential.userId,
},
appSlug: "lyra",
currentTokenObject: tokenResponse,
fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => {
if (!refreshToken) {
return null;
}
const { client_id, client_secret } = await getLyraAppKeys();
return fetch(`${LYRA_API_URL}/api/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id,
client_secret,
}),
});
},
isTokenObjectUnusable: async (response) => {
if (!response.ok) {
const responseToUseInCaseOfError = response.clone();
let responseBody: { error?: string };
try {
responseBody = await response.json();
} catch (_e) {
log.error(
"Lyra isTokenObjectUnusable: failed to parse response as JSON",
safeStringify({
status: response.status,
body: await responseToUseInCaseOfError.text().catch(() => "<unreadable>"),
})
);
return null;
}
if (responseBody.error === "invalid_grant") {
return { reason: responseBody.error };
}
}
return null;
},
isAccessTokenUnusable: async (response) => {
if (response.status === 401) {
return { reason: "access_token_expired" };
}
return null;
},
invalidateTokenObject: () => invalidateCredential(credential.id),
expireAccessToken: () => markTokenAsExpired(credential),
updateTokenObject: async (newTokenObject) => {
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: newTokenObject as unknown as Prisma.InputJsonValue,
},
});
},
});
const { json } = await auth.request({
url: `${LYRA_API_URL}${endpoint}`,
options: {
method: "GET",
...options,
headers: {
...options?.headers,
},
},
});
return json;
};
return {
getAvailability: async () => {
return [];
},
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> => {
log.debug("Creating Lyra meeting", { title: event.title });
try {
const data = (await fetchLyraApi("/api/v1/meeting", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: event.title,
start: event.startTime,
attendees: event.attendees.map((attendee) => attendee.email),
}),
})) as { id: string; url: string; password?: string };
log.debug("Lyra meeting created", { id: data.id, url: data.url });
return {
type: "lyra_video",
id: data.id,
password: data.password || "",
url: data.url,
};
} catch (err) {
log.error("Lyra meeting creation failed", safeStringify(err));
throw new Error("Failed to create Lyra meeting");
}
},
deleteMeeting: async (uid: string): Promise<void> => {
log.debug("Delete meeting called (no-op)", { uid });
},
updateMeeting: async (bookingRef: PartialReference, _event: CalendarEvent): Promise<VideoCallData> => {
log.debug("Update meeting called (no-op)", { uid: bookingRef.uid });
return {
type: "lyra_video",
id: bookingRef.meetingId as string,
password: bookingRef.meetingPassword as string,
url: bookingRef.meetingUrl as string,
};
},
};
};
export default LyraVideoApiAdapter;
+1
View File
@@ -0,0 +1 @@
export const LYRA_API_URL = "https://app.lyra.so";
@@ -0,0 +1,7 @@
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
import { appKeysSchema } from "../zod";
export const getLyraAppKeys = async () => {
const appKeys = await getAppKeysFromSlug("lyra");
return appKeysSchema.parse(appKeys);
};
+3
View File
@@ -0,0 +1,3 @@
export { LYRA_API_URL } from "./constants";
export { getLyraAppKeys } from "./getLyraAppKeys";
export { default as VideoApiAdapter } from "./VideoApiAdapter";
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@calcom/lyra",
"version": "0.0.0",
"private": true,
"main": "./index.ts",
"description": "The meeting platform built for pros.",
"dependencies": {
"@calcom/lib": "*",
"@calcom/prisma": "*"
},
"devDependencies": {
"@calcom/types": "*"
}
}
+3
View File
@@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.42703 2.09497H10.5668V6.54706C12.2547 4.2179 13.8669 3.77574 16.4277 3.77574H17.335V7.13838H16.341C14.0222 7.13838 12.7232 7.58856 11.3544 9.1505C12.7413 10.7017 14.0065 11.1914 16.341 11.1914H17.335V14.554H16.4277C13.8669 14.554 12.2547 14.1119 10.5668 11.7827V15.905H7.42703V11.7896C5.7414 14.1131 4.13062 14.554 1.57249 14.554H0.665001V11.1914H1.65898C3.99339 11.1914 5.25883 10.7017 6.64542 9.15081C5.27632 7.58903 3.97779 7.13854 1.65898 7.13854H0.665001V3.77574H1.57233C4.13062 3.77574 5.74125 4.21711 7.42687 6.5403L7.42703 2.09497Z" fill="#FF5207"/>
</svg>

After

Width:  |  Height:  |  Size: 673 B

+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
export const appKeysSchema = z.object({
client_id: z.string().min(1),
client_secret: z.string().min(1),
});
export const appDataSchema = z.object({});
@@ -10,6 +10,7 @@ export const VideoApiAdapterMap =
huddle01video: import("./huddle01video/lib/VideoApiAdapter"),
jelly: import("./jelly/lib/VideoApiAdapter"),
jitsivideo: import("./jitsivideo/lib/VideoApiAdapter"),
lyra: import("./lyra/lib/VideoApiAdapter"),
nextcloudtalk: import("./nextcloudtalk/lib/VideoApiAdapter"),
office365video: import("./office365video/lib/VideoApiAdapter"),
shimmervideo: import("./shimmervideo/lib/VideoApiAdapter"),
+10
View File
@@ -2776,6 +2776,16 @@ __metadata:
languageName: unknown
linkType: soft
"@calcom/lyra@workspace:packages/app-store/lyra":
version: 0.0.0-use.local
resolution: "@calcom/lyra@workspace:packages/app-store/lyra"
dependencies:
"@calcom/lib": "workspace:*"
"@calcom/prisma": "workspace:*"
"@calcom/types": "workspace:*"
languageName: unknown
linkType: soft
"@calcom/make@workspace:packages/app-store/make":
version: 0.0.0-use.local
resolution: "@calcom/make@workspace:packages/app-store/make"