diff --git a/apps/ai/.env.example b/apps/ai/.env.example
new file mode 100644
index 0000000000..e6effa0a1e
--- /dev/null
+++ b/apps/ai/.env.example
@@ -0,0 +1,21 @@
+BACKEND_URL=http://localhost:3002/api
+# BACKEND_URL=https://api.cal.com/v1
+FRONTEND_URL=http://localhost:3000
+# FRONTEND_URL=https://cal.com
+
+APP_ID=cal-ai
+APP_URL=http://localhost:3000/apps/cal-ai
+
+# This is for the onboard route. Which domain should we send emails from?
+SENDER_DOMAIN=cal.ai
+
+# Used to verify requests from sendgrid. You can generate a new one with: `openssl rand -hex 32`
+PARSE_KEY=
+
+OPENAI_API_KEY=
+
+# Optionally trace completions at https://smith.langchain.com
+# LANGCHAIN_TRACING_V2=true
+# LANGCHAIN_ENDPOINT=
+# LANGCHAIN_API_KEY=
+# LANGCHAIN_PROJECT=
diff --git a/apps/ai/README.md b/apps/ai/README.md
new file mode 100644
index 0000000000..306bf07906
--- /dev/null
+++ b/apps/ai/README.md
@@ -0,0 +1,68 @@
+# Cal.ai
+
+Welcome to [Cal.ai](https://cal.ai)!
+
+This app lets you chat with your calendar via email:
+
+- Turn informal emails into bookings eg. forward "wanna meet tmrw at 2pm?"
+- List and rearrange your bookings eg. "clear my afternoon"
+- Answer basic questions about your busiest times eg. "how does my Tuesday look?"
+
+The core logic is contained in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts). Here, a [LangChain Agent Executor](https://docs.langchain.com/docs/components/agents/agent-executor) is tasked with following your instructions. Given your last-known timezone, working hours, and busy times, it attempts to CRUD your bookings.
+
+_The AI agent can only choose from a set of tools, without ever seeing your API key._
+
+Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts) using [MailParser](https://nodemailer.com/extras/mailparser/).
+
+Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
+
+## Recognition
+
+
+
+## Getting Started
+
+### Development
+
+If you haven't yet, please run the [root setup](/README.md) steps.
+
+Before running the app, please see [env.mjs](./src/env.mjs) for all required environment variables. Run `cp .env.example .env` in this folder to get started. You'll need:
+
+- An [OpenAI API key](https://platform.openai.com/account/api-keys) with access to GPT-4
+- A [SendGrid API key](https://app.sendgrid.com/settings/api_keys)
+- A default sender email (for example, `me@dev.example.com`)
+- The Cal.ai app's ID and URL (see [add.ts](/packages/app-store/cal-ai/api/index.ts))
+- A unique value for `PARSE_KEY` with `openssl rand -hex 32`
+
+To stand up the API and AI apps simultaneously, simply run `yarn dev:ai`.
+
+### Agent Architecture
+
+The scheduling agent in [agent/route.ts](/apps/ai/src/app/api/agent/route.ts) calls an LLM (in this case, GPT-4) in a loop to accomplish a multi-step task. We use an [OpenAI Functions agent](https://js.langchain.com/docs/modules/agents/agent_types/openai_functions_agent), which is fine-tuned to output text suited for passing to tools.
+
+Tools (eg. [`createBooking`](/apps/ai/src/tools/createBooking.ts)) are simply JavaScript methods wrapped by Zod schemas, telling the agent what format to output.
+
+Here is the full architecture:
+
+
+
+### Email Router
+
+To expose the AI app, you can use either [Tunnelmole](https://github.com/robbie-cahill/tunnelmole-client), an open source tunnelling tool; or [nGrok](https://ngrok.com/), a popular closed source tunnelling tool.
+
+For Tunnelmole, run `tmole 3005` (or the AI app's port number) in a new terminal. Please replace `3005` with the port number if it is different. In the output, you'll see two URLs, one http and a https (we recommend using the https url for privacy and security). To install Tunnelmole, use `curl -O https://install.tunnelmole.com/8dPBw/install && sudo bash install`. (On Windows, download [tmole.exe](https://tunnelmole.com/downloads/tmole.exe))
+
+For nGrok, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install nGrok first.
+
+To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
+
+1. Ensure you have a [SendGrid account](https://signup.sendgrid.com/)
+2. Ensure you have an authenticated domain. Go to Settings > Sender Authentication > Authenticate. For DNS host, select `I'm not sure`. Click Next and add your domain, eg. `example.com`. Choose Manual Setup. You'll be given three CNAME records to add to your DNS settings, eg. in [Vercel Domains](https://vercel.com/dashboard/domains). After adding those records, click Verify. To troubleshoot, see the [full instructions](https://docs.sendgrid.com/ui/account-and-settings/how-to-set-up-domain-authentication).
+3. Authorize your domain for email with MX records: one with name `[your domain].com` and value `mx.sendgrid.net.`, and another with name `bounces.[your domain].com` and value `feedback-smtp.us-east-1.amazonses.com`. Set the priority to `10` if prompted.
+4. Go to Settings > [Inbound Parse](https://app.sendgrid.com/settings/parse) > Add Host & URL. Choose your authenticated domain.
+5. In the Destination URL field, use the Tunnelmole or ngrok URL from above along with the path, `/api/receive`, and one param, `parseKey`, which lives in [this app's .env](/apps/ai/.env.example) under `PARSE_KEY`. The full URL should look like `https://abc.tunnelmole.net/api/receive?parseKey=ABC-123` or `https://abc.ngrok.io/api/receive?parseKey=ABC-123`.
+6. Activate "POST the raw, full MIME message".
+7. Send an email to `[anyUsername]@example.com`. You should see a ping on the Tunnelmole or ngrok listener and server.
+8. Adjust the logic in [receive/route.ts](/apps/ai/src/app/api/receive/route.ts), save to hot-reload, and send another email to test the behaviour.
+
+Please feel free to improve any part of this architecture!
diff --git a/apps/ai/next-env.d.ts b/apps/ai/next-env.d.ts
new file mode 100644
index 0000000000..4f11a03dc6
--- /dev/null
+++ b/apps/ai/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/apps/ai/next.config.js b/apps/ai/next.config.js
new file mode 100644
index 0000000000..747ded2324
--- /dev/null
+++ b/apps/ai/next.config.js
@@ -0,0 +1,24 @@
+const withBundleAnalyzer = require("@next/bundle-analyzer");
+
+const plugins = [];
+plugins.push(withBundleAnalyzer({ enabled: process.env.ANALYZE === "true" }));
+
+/** @type {import("next").NextConfig} */
+const nextConfig = {
+ async redirects() {
+ return [
+ {
+ source: "/",
+ destination: "https://cal.com/ai",
+ permanent: true,
+ },
+ ];
+ },
+ i18n: {
+ defaultLocale: "en",
+ locales: ["en"],
+ },
+ reactStrictMode: true,
+};
+
+module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
diff --git a/apps/ai/package.json b/apps/ai/package.json
new file mode 100644
index 0000000000..3bd4127264
--- /dev/null
+++ b/apps/ai/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@calcom/ai",
+ "version": "1.2.1",
+ "private": true,
+ "author": "Cal.com Inc.",
+ "dependencies": {
+ "@calcom/prisma": "*",
+ "@langchain/core": "^0.1.26",
+ "@langchain/openai": "^0.0.14",
+ "@t3-oss/env-nextjs": "^0.6.1",
+ "langchain": "^0.1.17",
+ "mailparser": "^3.6.5",
+ "next": "^13.5.4",
+ "supports-color": "8.1.1",
+ "zod": "^3.22.4"
+ },
+ "devDependencies": {
+ "@types/mailparser": "^3.4.0"
+ },
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev -p 3005",
+ "format": "npx prettier . --write",
+ "lint": "eslint . --ext .ts,.js,.tsx,.jsx",
+ "lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
+ "start": "next start"
+ }
+}
diff --git a/apps/ai/src/app/api/agent/route.ts b/apps/ai/src/app/api/agent/route.ts
new file mode 100644
index 0000000000..0ffbfa36b0
--- /dev/null
+++ b/apps/ai/src/app/api/agent/route.ts
@@ -0,0 +1,55 @@
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+
+import agent from "../../../utils/agent";
+import sendEmail from "../../../utils/sendEmail";
+import { verifyParseKey } from "../../../utils/verifyParseKey";
+
+// Allow agent loop to run for up to 5 minutes
+export const maxDuration = 300;
+
+/**
+ * Launches a LangChain agent to process an incoming email,
+ * then sends the response to the user.
+ */
+export const POST = async (request: NextRequest) => {
+ const verified = verifyParseKey(request.url);
+
+ if (!verified) {
+ return new NextResponse("Unauthorized", { status: 401 });
+ }
+
+ const json = await request.json();
+
+ const { apiKey, userId, message, subject, user, users, replyTo: agentEmail } = json;
+
+ if ((!message && !subject) || !user) {
+ return new NextResponse("Missing fields", { status: 400 });
+ }
+
+ try {
+ const response = await agent(`${subject}\n\n${message}`, { ...user }, users, apiKey, userId, agentEmail);
+
+ // Send response to user
+ await sendEmail({
+ subject: `Re: ${subject}`,
+ text: response.replace(/(?:\r\n|\r|\n)/g, "\n"),
+ to: user.email,
+ from: agentEmail,
+ });
+
+ return new NextResponse("ok");
+ } catch (error) {
+ await sendEmail({
+ subject: `Re: ${subject}`,
+ text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
+ to: user.email,
+ from: agentEmail,
+ });
+
+ return new NextResponse(
+ (error as Error).message || "Something went wrong. Please try again or reach out for help.",
+ { status: 500 }
+ );
+ }
+};
diff --git a/apps/ai/src/app/api/onboard/route.ts b/apps/ai/src/app/api/onboard/route.ts
new file mode 100644
index 0000000000..e4eaaef8cf
--- /dev/null
+++ b/apps/ai/src/app/api/onboard/route.ts
@@ -0,0 +1,44 @@
+import type { NextRequest } from "next/server";
+
+import prisma from "@calcom/prisma";
+
+import { env } from "../../../env.mjs";
+import sendEmail from "../../../utils/sendEmail";
+
+export const POST = async (request: NextRequest) => {
+ const { userId } = await request.json();
+
+ const user = await prisma.user.findUnique({
+ select: {
+ email: true,
+ name: true,
+ username: true,
+ },
+ where: {
+ id: userId,
+ },
+ });
+
+ if (!user) {
+ return new Response("User not found", { status: 404 });
+ }
+
+ await sendEmail({
+ subject: "Welcome to Cal AI",
+ to: user.email,
+ from: `${user.username}@${env.SENDER_DOMAIN}`,
+ text: `Hi ${
+ user.name || `@${user.username}`
+ },\n\nI'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.\n\nHere are some things you can ask me:\n\n- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)\n- "What meetings do I have today?" (I'll show you your schedule)\n- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)\n\nI'm still learning, so if you have any feedback, please tweet it to @calcom!\n\nRemember, you can always reach me here, at ${
+ user.username
+ }@${
+ env.SENDER_DOMAIN
+ }.\n\nLooking forward to working together (:\n\n- Cal AI, Your personal booking assistant`,
+ html: `Hi ${
+ user.name || `@${user.username}`
+ },
I'm Cal AI, your personal booking assistant! I'll be here, 24/7 to help manage your busy schedule and find times to meet with the people you care about.
Here are some things you can ask me:
- "Book a meeting with @someone" (The @ symbol lets you tag Cal.com users)
- "What meetings do I have today?" (I'll show you your schedule)
- "Find a time for coffee with someone@gmail.com" (I'll intro and send them some good times)
I'm still learning, so if you have any feedback, please send it to @calcom on X!
Remember, you can always reach me here, at ${
+ user.username
+ }@${env.SENDER_DOMAIN}.
Looking forward to working together (:
- Cal AI`,
+ });
+ return new Response("OK", { status: 200 });
+};
diff --git a/apps/ai/src/app/api/receive/route.ts b/apps/ai/src/app/api/receive/route.ts
new file mode 100644
index 0000000000..68bbc51168
--- /dev/null
+++ b/apps/ai/src/app/api/receive/route.ts
@@ -0,0 +1,186 @@
+import type { ParsedMail, Source } from "mailparser";
+import { simpleParser } from "mailparser";
+import type { NextRequest } from "next/server";
+import { NextResponse } from "next/server";
+
+import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
+import prisma from "@calcom/prisma";
+
+import { env } from "../../../env.mjs";
+import { fetchAvailability } from "../../../tools/getAvailability";
+import { fetchEventTypes } from "../../../tools/getEventTypes";
+import { extractUsers } from "../../../utils/extractUsers";
+import getHostFromHeaders from "../../../utils/host";
+import now from "../../../utils/now";
+import sendEmail from "../../../utils/sendEmail";
+import { verifyParseKey } from "../../../utils/verifyParseKey";
+
+// Allow receive loop to run for up to 30 seconds
+// Why so long? the rate determining API call (getAvailability, getEventTypes) can take up to 15 seconds at peak times so we give it a little extra time to complete.
+export const maxDuration = 30;
+
+/**
+ * Verifies email signature and app authorization,
+ * then hands off to booking agent.
+ */
+export const POST = async (request: NextRequest) => {
+ const verified = verifyParseKey(request.url);
+
+ if (!verified) {
+ return new NextResponse("Unauthorized", { status: 401 });
+ }
+
+ const formData = await request.formData();
+ const body = Object.fromEntries(formData);
+ const envelope = JSON.parse(body.envelope as string);
+
+ const aiEmail = envelope.to[0];
+ const subject = body.subject || "";
+
+ try {
+ await checkRateLimitAndThrowError({
+ identifier: `ai:email:${envelope.from}`,
+ rateLimitingType: "ai",
+ });
+ } catch (error) {
+ await sendEmail({
+ subject: `Re: ${subject}`,
+ text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
+ to: envelope.from,
+ from: aiEmail,
+ });
+
+ return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
+ }
+
+ // Parse email from mixed MIME type
+ const parsed: ParsedMail = await simpleParser(body.email as Source);
+
+ if (!parsed.text && !parsed.subject) {
+ await sendEmail({
+ subject: `Re: ${subject}`,
+ text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
+ to: envelope.from,
+ from: aiEmail,
+ });
+ return new NextResponse("Email missing text and subject", { status: 400 });
+ }
+
+ const user = await prisma.user.findUnique({
+ select: {
+ email: true,
+ id: true,
+ username: true,
+ timeZone: true,
+ credentials: {
+ select: {
+ appId: true,
+ key: true,
+ },
+ },
+ },
+ where: { email: envelope.from },
+ });
+
+ // body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
+ const signature = (body.dkim as string).includes(" : pass");
+
+ // User is not a cal.com user or is using an unverified email.
+ if (!signature || !user) {
+ await sendEmail({
+ html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address and then install Cal.ai here: go.cal.com/ai.`,
+ subject: `Re: ${subject}`,
+ text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
+ to: envelope.from,
+ from: aiEmail,
+ });
+
+ return new NextResponse("ok");
+ }
+
+ const credential = user.credentials.find((c) => c.appId === env.APP_ID)?.key;
+
+ // User has not installed the app from the app store. Direct them to install it.
+ if (!(credential as { apiKey: string })?.apiKey) {
+ const url = env.APP_URL;
+
+ await sendEmail({
+ html: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install it.`,
+ subject: `Re: ${subject}`,
+ text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
+ to: envelope.from,
+ from: aiEmail,
+ });
+
+ return new NextResponse("ok");
+ }
+
+ const { apiKey } = credential as { apiKey: string };
+
+ // Pre-fetch data relevant to most bookings.
+ const [eventTypes, availability, users] = await Promise.all([
+ fetchEventTypes({
+ apiKey,
+ }),
+ fetchAvailability({
+ apiKey,
+ userId: user.id,
+ dateFrom: now(user.timeZone),
+ dateTo: now(user.timeZone),
+ }),
+ extractUsers(`${parsed.text} ${parsed.subject}`),
+ ]);
+
+ if ("error" in availability) {
+ await sendEmail({
+ subject: `Re: ${subject}`,
+ text: "Sorry, there was an error fetching your availability. Please try again.",
+ to: user.email,
+ from: aiEmail,
+ });
+ console.error(availability.error);
+ return new NextResponse("Error fetching availability. Please try again.", { status: 400 });
+ }
+
+ if ("error" in eventTypes) {
+ await sendEmail({
+ subject: `Re: ${subject}`,
+ text: "Sorry, there was an error fetching your event types. Please try again.",
+ to: user.email,
+ from: aiEmail,
+ });
+ console.error(eventTypes.error);
+ return new NextResponse("Error fetching event types. Please try again.", { status: 400 });
+ }
+
+ const { workingHours } = availability;
+
+ const appHost = getHostFromHeaders(request.headers);
+
+ // Hand off to long-running agent endpoint to handle the email. (don't await)
+ fetch(`${appHost}/api/agent?parseKey=${env.PARSE_KEY}`, {
+ body: JSON.stringify({
+ apiKey,
+ userId: user.id,
+ message: parsed.text || "",
+ subject: parsed.subject || "",
+ replyTo: aiEmail,
+ user: {
+ email: user.email,
+ eventTypes,
+ username: user.username,
+ timeZone: user.timeZone,
+ workingHours,
+ },
+ users,
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+
+ await new Promise((r) => setTimeout(r, 1000));
+
+ return new NextResponse("ok");
+};
diff --git a/apps/ai/src/env.mjs b/apps/ai/src/env.mjs
new file mode 100644
index 0000000000..2596a26643
--- /dev/null
+++ b/apps/ai/src/env.mjs
@@ -0,0 +1,47 @@
+import { createEnv } from "@t3-oss/env-nextjs";
+import { z } from "zod";
+
+export const env = createEnv({
+ /**
+ * Specify your client-side environment variables schema here. This way you can ensure the app
+ * isn't built with invalid env vars. To expose them to the client, prefix them with
+ * `NEXT_PUBLIC_`.
+ */
+ client: {
+ // NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
+ },
+
+ /**
+ * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
+ * middlewares) or client-side so we need to destruct manually.
+ */
+ runtimeEnv: {
+ BACKEND_URL: process.env.BACKEND_URL,
+ FRONTEND_URL: process.env.FRONTEND_URL,
+ APP_ID: process.env.APP_ID,
+ APP_URL: process.env.APP_URL,
+ SENDER_DOMAIN: process.env.SENDER_DOMAIN,
+ PARSE_KEY: process.env.PARSE_KEY,
+ NODE_ENV: process.env.NODE_ENV,
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
+ SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
+ DATABASE_URL: process.env.DATABASE_URL,
+ },
+
+ /**
+ * Specify your server-side environment variables schema here. This way you can ensure the app
+ * isn't built with invalid env vars.
+ */
+ server: {
+ BACKEND_URL: z.string().url(),
+ FRONTEND_URL: z.string().url(),
+ APP_ID: z.string().min(1),
+ APP_URL: z.string().url(),
+ SENDER_DOMAIN: z.string().min(1),
+ PARSE_KEY: z.string().min(1),
+ NODE_ENV: z.enum(["development", "test", "production"]),
+ OPENAI_API_KEY: z.string().min(1),
+ SENDGRID_API_KEY: z.string().min(1),
+ DATABASE_URL: z.string().url(),
+ },
+});
diff --git a/apps/ai/src/public/architecture.png b/apps/ai/src/public/architecture.png
new file mode 100644
index 0000000000..52eedfe6f5
Binary files /dev/null and b/apps/ai/src/public/architecture.png differ
diff --git a/apps/ai/src/tools/createBooking.ts b/apps/ai/src/tools/createBooking.ts
new file mode 100644
index 0000000000..40bb4d0fc1
--- /dev/null
+++ b/apps/ai/src/tools/createBooking.ts
@@ -0,0 +1,121 @@
+import { DynamicStructuredTool } from "@langchain/core/tools";
+import { z } from "zod";
+
+import type { UserList } from "~/src/types/user";
+
+import { env } from "../env.mjs";
+
+/**
+ * Creates a booking for a user by event type, times, and timezone.
+ */
+const createBooking = async ({
+ apiKey,
+ userId,
+ users,
+ eventTypeId,
+ start,
+ end,
+ timeZone,
+ language,
+ invite,
+}: {
+ apiKey: string;
+ userId: number;
+ users: UserList;
+ eventTypeId: number;
+ start: string;
+ end: string;
+ timeZone: string;
+ language: string;
+ invite: number;
+ title?: string;
+ status?: string;
+}): Promise => {
+ const params = {
+ apiKey,
+ userId: userId.toString(),
+ };
+
+ const urlParams = new URLSearchParams(params);
+
+ const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
+
+ const user = users.find((u) => u.id === invite);
+
+ if (!user) {
+ return { error: `User with id ${invite} not found to invite` };
+ }
+
+ const responses = {
+ id: invite.toString(),
+ name: user.username,
+ email: user.email,
+ };
+
+ const response = await fetch(url, {
+ body: JSON.stringify({
+ end,
+ eventTypeId,
+ language,
+ metadata: {},
+ responses,
+ start,
+ timeZone,
+ }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "POST",
+ });
+
+ // Let GPT handle this. This will happen when wrong event type id is used.
+ // if (response.status === 401) throw new Error("Unauthorized");
+
+ const data = await response.json();
+
+ if (response.status !== 200) {
+ return {
+ error: data.message,
+ };
+ }
+
+ return "Booking created";
+};
+
+const createBookingTool = (apiKey: string, userId: number, users: UserList) => {
+ return new DynamicStructuredTool({
+ description: "Creates a booking on the primary user's calendar.",
+ func: async ({ eventTypeId, start, end, timeZone, language, invite, title, status }) => {
+ return JSON.stringify(
+ await createBooking({
+ apiKey,
+ userId,
+ users,
+ end,
+ eventTypeId,
+ language,
+ invite,
+ start,
+ status,
+ timeZone,
+ title,
+ })
+ );
+ },
+ name: "createBooking",
+ schema: z.object({
+ end: z
+ .string()
+ .describe("This should correspond to the event type's length, unless otherwise specified."),
+ eventTypeId: z.number(),
+ language: z.string(),
+ invite: z.number().describe("External user id to invite."),
+ start: z.string(),
+ status: z.string().optional().describe("ACCEPTED, PENDING, CANCELLED or REJECTED"),
+ timeZone: z.string(),
+ title: z.string().optional(),
+ }),
+ });
+};
+
+export default createBookingTool;
diff --git a/apps/ai/src/tools/deleteBooking.ts b/apps/ai/src/tools/deleteBooking.ts
new file mode 100644
index 0000000000..981cef8f58
--- /dev/null
+++ b/apps/ai/src/tools/deleteBooking.ts
@@ -0,0 +1,66 @@
+import { DynamicStructuredTool } from "@langchain/core/tools";
+import { z } from "zod";
+
+import { env } from "../env.mjs";
+
+/**
+ * Cancels a booking for a user by ID with reason.
+ */
+const cancelBooking = async ({
+ apiKey,
+ id,
+ reason,
+}: {
+ apiKey: string;
+ id: string;
+ reason: string;
+}): Promise => {
+ const params = {
+ apiKey,
+ };
+
+ const urlParams = new URLSearchParams(params);
+
+ const url = `${env.BACKEND_URL}/bookings/${id}/cancel?${urlParams.toString()}`;
+
+ const response = await fetch(url, {
+ body: JSON.stringify({ reason }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "DELETE",
+ });
+
+ // Let GPT handle this. This will happen when wrong booking id is used.
+ // if (response.status === 401) throw new Error("Unauthorized");
+
+ const data = await response.json();
+
+ if (response.status !== 200) {
+ return { error: data.message };
+ }
+
+ return "Booking cancelled";
+};
+
+const cancelBookingTool = (apiKey: string) => {
+ return new DynamicStructuredTool({
+ description: "Cancel a booking",
+ func: async ({ id, reason }) => {
+ return JSON.stringify(
+ await cancelBooking({
+ apiKey,
+ id,
+ reason,
+ })
+ );
+ },
+ name: "cancelBooking",
+ schema: z.object({
+ id: z.string(),
+ reason: z.string(),
+ }),
+ });
+};
+
+export default cancelBookingTool;
diff --git a/apps/ai/src/tools/getAvailability.ts b/apps/ai/src/tools/getAvailability.ts
new file mode 100644
index 0000000000..26258e0c60
--- /dev/null
+++ b/apps/ai/src/tools/getAvailability.ts
@@ -0,0 +1,77 @@
+import { DynamicStructuredTool } from "@langchain/core/tools";
+import { z } from "zod";
+
+import { env } from "../env.mjs";
+import type { Availability } from "../types/availability";
+
+/**
+ * Fetches availability for a user by date range and event type.
+ */
+export const fetchAvailability = async ({
+ apiKey,
+ userId,
+ dateFrom,
+ dateTo,
+}: {
+ apiKey: string;
+ userId: number;
+ dateFrom: string;
+ dateTo: string;
+}): Promise | { error: string }> => {
+ const params: { [k: string]: string } = {
+ apiKey,
+ userId: userId.toString(),
+ dateFrom,
+ dateTo,
+ };
+
+ const urlParams = new URLSearchParams(params);
+
+ const url = `${env.BACKEND_URL}/availability?${urlParams.toString()}`;
+
+ const response = await fetch(url);
+
+ if (response.status === 401) throw new Error("Unauthorized");
+
+ const data = await response.json();
+
+ if (response.status !== 200) {
+ return { error: data.message };
+ }
+
+ return {
+ busy: data.busy,
+ dateRanges: data.dateRanges,
+ timeZone: data.timeZone,
+ workingHours: data.workingHours,
+ };
+};
+
+const getAvailabilityTool = (apiKey: string) => {
+ return new DynamicStructuredTool({
+ description: "Get availability of users within range.",
+ func: async ({ userIds, dateFrom, dateTo }) => {
+ return JSON.stringify(
+ await Promise.all(
+ userIds.map(
+ async (userId) =>
+ await fetchAvailability({
+ userId: userId,
+ apiKey,
+ dateFrom,
+ dateTo,
+ })
+ )
+ )
+ );
+ },
+ name: "getAvailability",
+ schema: z.object({
+ userIds: z.array(z.number()).describe("The users to fetch availability for."),
+ dateFrom: z.string(),
+ dateTo: z.string(),
+ }),
+ });
+};
+
+export default getAvailabilityTool;
diff --git a/apps/ai/src/tools/getBookings.ts b/apps/ai/src/tools/getBookings.ts
new file mode 100644
index 0000000000..6976f31ecf
--- /dev/null
+++ b/apps/ai/src/tools/getBookings.ts
@@ -0,0 +1,75 @@
+import { DynamicStructuredTool } from "@langchain/core/tools";
+import { z } from "zod";
+
+import { env } from "../env.mjs";
+import type { Booking } from "../types/booking";
+import { BOOKING_STATUS } from "../types/booking";
+
+/**
+ * Fetches bookings for a user by date range.
+ */
+const fetchBookings = async ({
+ apiKey,
+ userId,
+ from,
+ to,
+}: {
+ apiKey: string;
+ userId: number;
+ from: string;
+ to: string;
+}): Promise => {
+ const params = {
+ apiKey,
+ userId: userId.toString(),
+ dateFrom: from,
+ dateTo: to,
+ };
+
+ const urlParams = new URLSearchParams(params);
+
+ const url = `${env.BACKEND_URL}/bookings?${urlParams.toString()}`;
+
+ const response = await fetch(url);
+
+ if (response.status === 401) throw new Error("Unauthorized");
+
+ const data = await response.json();
+
+ if (response.status !== 200) {
+ return { error: data.message };
+ }
+
+ const bookings: Booking[] = data.bookings
+ .filter((booking: Booking) => {
+ const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
+
+ return notCancelled;
+ })
+ .map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
+ endTime,
+ eventTypeId,
+ id,
+ startTime,
+ status,
+ title,
+ }));
+
+ return bookings;
+};
+
+const getBookingsTool = (apiKey: string, userId: number) => {
+ return new DynamicStructuredTool({
+ description: "Get bookings for the primary user between two dates.",
+ func: async ({ from, to }) => {
+ return JSON.stringify(await fetchBookings({ apiKey, userId, from, to }));
+ },
+ name: "getBookings",
+ schema: z.object({
+ from: z.string().describe("ISO 8601 datetime string"),
+ to: z.string().describe("ISO 8601 datetime string"),
+ }),
+ });
+};
+
+export default getBookingsTool;
diff --git a/apps/ai/src/tools/getEventTypes.ts b/apps/ai/src/tools/getEventTypes.ts
new file mode 100644
index 0000000000..c59df69c99
--- /dev/null
+++ b/apps/ai/src/tools/getEventTypes.ts
@@ -0,0 +1,59 @@
+import { DynamicStructuredTool } from "@langchain/core/tools";
+import { z } from "zod";
+
+import { env } from "../env.mjs";
+import type { EventType } from "../types/eventType";
+
+/**
+ * Fetches event types by user ID.
+ */
+export const fetchEventTypes = async ({ apiKey, userId }: { apiKey: string; userId?: number }) => {
+ const params: Record = {
+ apiKey,
+ };
+
+ if (userId) {
+ params["userId"] = userId.toString();
+ }
+
+ const urlParams = new URLSearchParams(params);
+
+ const url = `${env.BACKEND_URL}/event-types?${urlParams.toString()}`;
+
+ const response = await fetch(url);
+
+ if (response.status === 401) throw new Error("Unauthorized");
+
+ const data = await response.json();
+
+ if (response.status !== 200) {
+ return { error: data.message };
+ }
+
+ return data.event_types.map((eventType: EventType) => ({
+ id: eventType.id,
+ slug: eventType.slug,
+ length: eventType.length,
+ title: eventType.title,
+ }));
+};
+
+const getEventTypesTool = (apiKey: string) => {
+ return new DynamicStructuredTool({
+ description: "Get a user's event type IDs. Usually necessary to book a meeting.",
+ func: async ({ userId }) => {
+ return JSON.stringify(
+ await fetchEventTypes({
+ apiKey,
+ userId,
+ })
+ );
+ },
+ name: "getEventTypes",
+ schema: z.object({
+ userId: z.number().optional().describe("The user ID. Defaults to the primary user's ID."),
+ }),
+ });
+};
+
+export default getEventTypesTool;
diff --git a/apps/ai/src/tools/sendBookingEmail.ts b/apps/ai/src/tools/sendBookingEmail.ts
new file mode 100644
index 0000000000..2133f61769
--- /dev/null
+++ b/apps/ai/src/tools/sendBookingEmail.ts
@@ -0,0 +1,124 @@
+import { DynamicStructuredTool } from "@langchain/core/tools";
+import { z } from "zod";
+
+import { env } from "~/src/env.mjs";
+import type { User, UserList } from "~/src/types/user";
+import sendEmail from "~/src/utils/sendEmail";
+
+export const sendBookingEmail = async ({
+ user,
+ agentEmail,
+ subject,
+ to,
+ message,
+ eventTypeSlug,
+ slots,
+ date,
+}: {
+ apiKey: string;
+ user: User;
+ users: UserList;
+ agentEmail: string;
+ subject: string;
+ to: string;
+ message: string;
+ eventTypeSlug: string;
+ slots?: {
+ time: string;
+ text: string;
+ }[];
+ date: {
+ date: string;
+ text: string;
+ };
+}) => {
+ // const url = `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date}`;
+ const timeUrls = slots?.map(({ time, text }) => {
+ return {
+ url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?slot=${time}`,
+ text,
+ };
+ });
+
+ const dateUrl = {
+ url: `${env.FRONTEND_URL}/${user.username}/${eventTypeSlug}?date=${date.date}`,
+ text: date.text,
+ };
+
+ await sendEmail({
+ subject,
+ to,
+ cc: user.email,
+ from: agentEmail,
+ text: message
+ .split("[[[Slots]]]")
+ .join(timeUrls?.map(({ url, text }) => `${text}: ${url}`).join("\n"))
+ .split("[[[Link]]]")
+ .join(`${dateUrl.text}: ${dateUrl.url}`),
+ html: message
+ .split("\n")
+ .join("
")
+ .split("[[[Slots]]]")
+ .join(timeUrls?.map(({ url, text }) => `${text}`).join("
"))
+ .split("[[[Link]]]")
+ .join(`${dateUrl.text}`),
+ });
+
+ return "Booking link sent";
+};
+
+const sendBookingEmailTool = (apiKey: string, user: User, users: UserList, agentEmail: string) => {
+ return new DynamicStructuredTool({
+ description:
+ "Send a booking link via email. Useful for scheduling with non cal users. Be confident, suggesting a good date/time with a fallback to a link to select a date/time.",
+ func: async ({ message, subject, to, eventTypeSlug, slots, date }) => {
+ return JSON.stringify(
+ await sendBookingEmail({
+ apiKey,
+ user,
+ users,
+ agentEmail,
+ subject,
+ to,
+ message,
+ eventTypeSlug,
+ slots,
+ date,
+ })
+ );
+ },
+ name: "sendBookingEmail",
+
+ schema: z.object({
+ message: z
+ .string()
+ .describe(
+ "A polite and professional email with an intro and signature at the end. Specify you are the AI booking assistant of the primary user. Use [[[Slots]]] and a fallback [[[Link]]] to inject good times and 'see all times' into messages"
+ ),
+ subject: z.string(),
+ to: z
+ .string()
+ .describe("email address to send the booking link to. Primary user is automatically CC'd"),
+ eventTypeSlug: z.string().describe("the slug of the event type to book"),
+ slots: z
+ .array(
+ z.object({
+ time: z.string().describe("YYYY-MM-DDTHH:mm in UTC"),
+ text: z.string().describe("minimum readable label. Ex. 4pm."),
+ })
+ )
+ .optional()
+ .describe("Time slots the external user can click"),
+ date: z
+ .object({
+ date: z.string().describe("YYYY-MM-DD"),
+ text: z.string().describe('"See all times" or similar'),
+ })
+ .describe(
+ "A booking link that allows the external user to select a date / time. Should be a fallback to time slots"
+ ),
+ }),
+ });
+};
+
+export default sendBookingEmailTool;
diff --git a/apps/ai/src/tools/updateBooking.ts b/apps/ai/src/tools/updateBooking.ts
new file mode 100644
index 0000000000..eec462e897
--- /dev/null
+++ b/apps/ai/src/tools/updateBooking.ts
@@ -0,0 +1,85 @@
+import { DynamicStructuredTool } from "@langchain/core/tools";
+import { z } from "zod";
+
+import { env } from "../env.mjs";
+
+/**
+ * Edits a booking for a user by booking ID with new times, title, description, or status.
+ */
+const editBooking = async ({
+ apiKey,
+ userId,
+ id,
+ startTime, // In the docs it says start, but it's startTime: https://cal.com/docs/enterprise-features/api/api-reference/bookings#edit-an-existing-booking.
+ endTime, // Same here: it says end but it's endTime.
+ title,
+ description,
+ status,
+}: {
+ apiKey: string;
+ userId: number;
+ id: string;
+ startTime?: string;
+ endTime?: string;
+ title?: string;
+ description?: string;
+ status?: string;
+}): Promise => {
+ const params = {
+ apiKey,
+ userId: userId.toString(),
+ };
+ const urlParams = new URLSearchParams(params);
+
+ const url = `${env.BACKEND_URL}/bookings/${id}?${urlParams.toString()}`;
+
+ const response = await fetch(url, {
+ body: JSON.stringify({ description, endTime, startTime, status, title }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "PATCH",
+ });
+
+ // Let GPT handle this. This will happen when wrong booking id is used.
+ // if (response.status === 401) throw new Error("Unauthorized");
+
+ const data = await response.json();
+
+ if (response.status !== 200) {
+ return { error: data.message };
+ }
+
+ return "Booking edited";
+};
+
+const editBookingTool = (apiKey: string, userId: number) => {
+ return new DynamicStructuredTool({
+ description: "Edit a booking",
+ func: async ({ description, endTime, id, startTime, status, title }) => {
+ return JSON.stringify(
+ await editBooking({
+ apiKey,
+ userId,
+ description,
+ endTime,
+ id,
+ startTime,
+ status,
+ title,
+ })
+ );
+ },
+ name: "editBooking",
+ schema: z.object({
+ description: z.string().optional(),
+ endTime: z.string().optional(),
+ id: z.string(),
+ startTime: z.string().optional(),
+ status: z.string().optional(),
+ title: z.string().optional(),
+ }),
+ });
+};
+
+export default editBookingTool;
diff --git a/apps/ai/src/types/availability.ts b/apps/ai/src/types/availability.ts
new file mode 100644
index 0000000000..62ed3b5ed5
--- /dev/null
+++ b/apps/ai/src/types/availability.ts
@@ -0,0 +1,25 @@
+export type Availability = {
+ busy: {
+ start: string;
+ end: string;
+ title?: string;
+ }[];
+ timeZone: string;
+ dateRanges: {
+ start: string;
+ end: string;
+ }[];
+ workingHours: {
+ days: number[];
+ startTime: number;
+ endTime: number;
+ userId: number;
+ }[];
+ dateOverrides: {
+ date: string;
+ startTime: number;
+ endTime: number;
+ userId: number;
+ };
+ currentSeats: number;
+};
diff --git a/apps/ai/src/types/booking.ts b/apps/ai/src/types/booking.ts
new file mode 100644
index 0000000000..91eaf4e322
--- /dev/null
+++ b/apps/ai/src/types/booking.ts
@@ -0,0 +1,23 @@
+export enum BOOKING_STATUS {
+ ACCEPTED = "ACCEPTED",
+ PENDING = "PENDING",
+ CANCELLED = "CANCELLED",
+ REJECTED = "REJECTED",
+}
+
+export type Booking = {
+ id: number;
+ userId: number;
+ description: string | null;
+ eventTypeId: number;
+ uid: string;
+ title: string;
+ startTime: string;
+ endTime: string;
+ attendees: { email: string; name: string; timeZone: string; locale: string }[] | null;
+ user: { email: string; name: string; timeZone: string; locale: string }[] | null;
+ payment: { id: number; success: boolean; paymentOption: string }[];
+ metadata: object | null;
+ status: BOOKING_STATUS;
+ responses: { email: string; name: string; location: string } | null;
+};
diff --git a/apps/ai/src/types/eventType.ts b/apps/ai/src/types/eventType.ts
new file mode 100644
index 0000000000..b9de4cc230
--- /dev/null
+++ b/apps/ai/src/types/eventType.ts
@@ -0,0 +1,13 @@
+export type EventType = {
+ id: number;
+ title: string;
+ length: number;
+ metadata: object;
+ slug: string;
+ hosts: {
+ userId: number;
+ isFixed: boolean;
+ }[];
+ hidden: boolean;
+ // ...
+};
diff --git a/apps/ai/src/types/user.ts b/apps/ai/src/types/user.ts
new file mode 100644
index 0000000000..e39b1b234b
--- /dev/null
+++ b/apps/ai/src/types/user.ts
@@ -0,0 +1,18 @@
+import type { EventType } from "./eventType";
+import type { WorkingHours } from "./workingHours";
+
+export type User = {
+ id: number;
+ email: string;
+ username: string;
+ timeZone: string;
+ eventTypes: EventType[];
+ workingHours: WorkingHours[];
+};
+
+export type UserList = {
+ id?: number;
+ email?: string;
+ username?: string;
+ type: "fromUsername" | "fromEmail";
+}[];
diff --git a/apps/ai/src/types/workingHours.ts b/apps/ai/src/types/workingHours.ts
new file mode 100644
index 0000000000..e3a1905a45
--- /dev/null
+++ b/apps/ai/src/types/workingHours.ts
@@ -0,0 +1,5 @@
+export type WorkingHours = {
+ days: number[];
+ startTime: number;
+ endTime: number;
+};
diff --git a/apps/ai/src/utils/agent.ts b/apps/ai/src/utils/agent.ts
new file mode 100644
index 0000000000..4ef72f10cc
--- /dev/null
+++ b/apps/ai/src/utils/agent.ts
@@ -0,0 +1,122 @@
+import { ChatPromptTemplate } from "@langchain/core/prompts";
+import { ChatOpenAI } from "@langchain/openai";
+import { AgentExecutor, createOpenAIFunctionsAgent } from "langchain/agents";
+
+import { env } from "../env.mjs";
+import createBookingIfAvailable from "../tools/createBooking";
+import deleteBooking from "../tools/deleteBooking";
+import getAvailability from "../tools/getAvailability";
+import getBookings from "../tools/getBookings";
+import sendBookingEmail from "../tools/sendBookingEmail";
+import updateBooking from "../tools/updateBooking";
+import type { EventType } from "../types/eventType";
+import type { User, UserList } from "../types/user";
+import type { WorkingHours } from "../types/workingHours";
+import now from "./now";
+
+const gptModel = "gpt-4-0125-preview";
+
+/**
+ * Core of the Cal.ai booking agent: a LangChain Agent Executor.
+ * Uses a toolchain to book meetings, list available slots, etc.
+ * Uses OpenAI functions to better enforce JSON-parsable output from the LLM.
+ */
+const agent = async (
+ input: string,
+ user: User,
+ users: UserList,
+ apiKey: string,
+ userId: number,
+ agentEmail: string
+) => {
+ const tools = [
+ // getEventTypes(apiKey),
+ getAvailability(apiKey),
+ getBookings(apiKey, userId),
+ createBookingIfAvailable(apiKey, userId, users),
+ updateBooking(apiKey, userId),
+ deleteBooking(apiKey),
+ sendBookingEmail(apiKey, user, users, agentEmail),
+ ];
+
+ const model = new ChatOpenAI({
+ modelName: gptModel,
+ openAIApiKey: env.OPENAI_API_KEY,
+ temperature: 0,
+ });
+
+ /**
+ * Initialize the agent executor with arguments.
+ */
+ const prompt =
+ ChatPromptTemplate.fromTemplate(`You are Cal.ai - a bleeding edge scheduling assistant that interfaces via email.
+ Make sure your final answers are definitive, complete and well formatted.
+ Sometimes, tools return errors. In this case, try to handle the error intelligently or ask the user for more information.
+ Tools will always handle times in UTC, but times sent to users should be formatted per that user's timezone.
+ In responses to users, always summarize necessary context and open the door to follow ups. For example "I have booked your chat with @username for 3pm on Wednesday, December 20th, 2023 EST. Please let me know if you need to reschedule."
+ If you can't find a referenced user, ask the user for their email or @username. Make sure to specify that usernames require the @username format. Users don't know other users' userIds.
+
+ The primary user's id is: ${userId}
+ The primary user's username is: ${user.username}
+ The current time in the primary user's timezone is: ${now(user.timeZone, {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "numeric",
+ minute: "numeric",
+ })}
+ The primary user's time zone is: ${user.timeZone}
+ The primary user's event types are: ${user.eventTypes
+ .map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
+ .join("\n")}
+ The primary user's working hours are: ${user.workingHours
+ .map(
+ (w: WorkingHours) =>
+ `Days: ${w.days.join(", ")}, Start Time (minutes in UTC): ${
+ w.startTime
+ }, End Time (minutes in UTC): ${w.endTime};`
+ )
+ .join("\n")}
+ ${
+ users.length
+ ? `The email references the following @usernames and emails: ${users
+ .map(
+ (u) =>
+ `${
+ (u.id ? `, id: ${u.id}` : "id: (non user)") +
+ (u.username
+ ? u.type === "fromUsername"
+ ? `, username: @${u.username}`
+ : ", username: REDACTED"
+ : ", (no username)") +
+ (u.email
+ ? u.type === "fromEmail"
+ ? `, email: ${u.email}`
+ : ", email: REDACTED"
+ : ", (no email)")
+ };`
+ )
+ .join("\n")}`
+ : ""
+ }`);
+ const agent = await createOpenAIFunctionsAgent({
+ llm: model,
+ prompt,
+ tools,
+ });
+
+ const executor = new AgentExecutor({
+ agent,
+ tools,
+ returnIntermediateSteps: env.NODE_ENV === "development",
+ verbose: env.NODE_ENV === "development",
+ });
+
+ const result = await executor.invoke({ input });
+ const { output } = result;
+
+ return output;
+};
+
+export default agent;
diff --git a/apps/ai/src/utils/context.ts b/apps/ai/src/utils/context.ts
new file mode 100644
index 0000000000..26864e73d6
--- /dev/null
+++ b/apps/ai/src/utils/context.ts
@@ -0,0 +1 @@
+export const context = { apiKey: "", userId: "" };
diff --git a/apps/ai/src/utils/extractUsers.ts b/apps/ai/src/utils/extractUsers.ts
new file mode 100644
index 0000000000..10a6f9fe84
--- /dev/null
+++ b/apps/ai/src/utils/extractUsers.ts
@@ -0,0 +1,85 @@
+import prisma from "@calcom/prisma";
+
+import type { UserList } from "../types/user";
+
+/*
+ * Extracts usernames (@Example) and emails (hi@example.com) from a string
+ */
+export const extractUsers = async (text: string) => {
+ const usernames = text
+ .match(/(? username.slice(1).toLowerCase());
+ const emails = text
+ .match(/[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+/g)
+ ?.map((email) => email.toLowerCase());
+
+ const dbUsersFromUsernames = usernames
+ ? await prisma.user.findMany({
+ select: {
+ id: true,
+ username: true,
+ email: true,
+ },
+ where: {
+ username: {
+ in: usernames,
+ },
+ },
+ })
+ : [];
+
+ const usersFromUsernames = usernames
+ ? usernames.map((username) => {
+ const user = dbUsersFromUsernames.find((u) => u.username === username);
+ return user
+ ? {
+ username,
+ id: user.id,
+ email: user.email,
+ type: "fromUsername",
+ }
+ : {
+ username,
+ id: null,
+ email: null,
+ type: "fromUsername",
+ };
+ })
+ : [];
+
+ const dbUsersFromEmails = emails
+ ? await prisma.user.findMany({
+ select: {
+ id: true,
+ email: true,
+ username: true,
+ },
+ where: {
+ email: {
+ in: emails,
+ },
+ },
+ })
+ : [];
+
+ const usersFromEmails = emails
+ ? emails.map((email) => {
+ const user = dbUsersFromEmails.find((u) => u.email === email);
+ return user
+ ? {
+ email,
+ id: user.id,
+ username: user.username,
+ type: "fromEmail",
+ }
+ : {
+ email,
+ id: null,
+ username: null,
+ type: "fromEmail",
+ };
+ })
+ : [];
+
+ return [...usersFromUsernames, ...usersFromEmails] as UserList;
+};
diff --git a/apps/ai/src/utils/host.ts b/apps/ai/src/utils/host.ts
new file mode 100644
index 0000000000..f215d7e7d0
--- /dev/null
+++ b/apps/ai/src/utils/host.ts
@@ -0,0 +1,7 @@
+import type { NextRequest } from "next/server";
+
+const getHostFromHeaders = (headers: NextRequest["headers"]): string => {
+ return `https://${headers.get("host")}`;
+};
+
+export default getHostFromHeaders;
diff --git a/apps/ai/src/utils/now.ts b/apps/ai/src/utils/now.ts
new file mode 100644
index 0000000000..98630d7bc4
--- /dev/null
+++ b/apps/ai/src/utils/now.ts
@@ -0,0 +1,6 @@
+export default function now(timeZone: string, options: Intl.DateTimeFormatOptions = {}) {
+ return new Date().toLocaleString("en-US", {
+ timeZone,
+ ...options,
+ });
+}
diff --git a/apps/ai/src/utils/sendEmail.ts b/apps/ai/src/utils/sendEmail.ts
new file mode 100644
index 0000000000..adbc9693e7
--- /dev/null
+++ b/apps/ai/src/utils/sendEmail.ts
@@ -0,0 +1,43 @@
+import mail from "@sendgrid/mail";
+
+const sendgridAPIKey = process.env.SENDGRID_API_KEY as string;
+
+/**
+ * Simply send an email by address, subject, and body.
+ */
+const send = async ({
+ subject,
+ to,
+ cc,
+ from,
+ text,
+ html,
+}: {
+ subject: string;
+ to: string | string[];
+ cc?: string | string[];
+ from: string;
+ text: string;
+ html?: string;
+}): Promise => {
+ mail.setApiKey(sendgridAPIKey);
+
+ const msg = {
+ to,
+ cc,
+ from: {
+ email: from,
+ name: "Cal.ai",
+ },
+ text,
+ html,
+ subject,
+ };
+
+ const res = await mail.send(msg);
+ const success = !!res;
+
+ return success;
+};
+
+export default send;
diff --git a/apps/ai/src/utils/verifyParseKey.ts b/apps/ai/src/utils/verifyParseKey.ts
new file mode 100644
index 0000000000..5ac81ed78c
--- /dev/null
+++ b/apps/ai/src/utils/verifyParseKey.ts
@@ -0,0 +1,13 @@
+import type { NextRequest } from "next/server";
+
+import { env } from "../env.mjs";
+
+/**
+ * Verifies that the request contains the correct parse key.
+ * env.PARSE_KEY must be configured as a query param in the sendgrid inbound parse settings.
+ */
+export const verifyParseKey = (url: NextRequest["url"]) => {
+ const verified = new URL(url).searchParams.get("parseKey") === env.PARSE_KEY;
+
+ return verified;
+};
diff --git a/apps/ai/tsconfig.json b/apps/ai/tsconfig.json
new file mode 100644
index 0000000000..aae1b4e758
--- /dev/null
+++ b/apps/ai/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "@calcom/tsconfig/nextjs.json",
+ "compilerOptions": {
+ "strict": true,
+ "jsx": "preserve",
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["*"]
+ },
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ "../../packages/types/window.d.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/app-store/apps.browser.generated.tsx b/packages/app-store/apps.browser.generated.tsx
index e623dbc919..4fea31051f 100644
--- a/packages/app-store/apps.browser.generated.tsx
+++ b/packages/app-store/apps.browser.generated.tsx
@@ -11,6 +11,7 @@ export const InstallAppButtonMap = {
vital: dynamic(() => import("./vital/components/InstallAppButton")),
};
export const AppSettingsComponentsMap = {
+ "cal-ai": dynamic(() => import("./cal-ai/components/AppSettingsInterface")),
"general-app-settings": dynamic(() =>
import("./templates/general-app-settings/components/AppSettingsInterface")
),
diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts
index 46f6dfc241..b50a5ae7d2 100644
--- a/packages/app-store/apps.metadata.generated.ts
+++ b/packages/app-store/apps.metadata.generated.ts
@@ -9,6 +9,7 @@ import around_config_json from "./around/config.json";
import autocheckin_config_json from "./autocheckin/config.json";
import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
import basecamp3_config_json from "./basecamp3/config.json";
+import cal_ai_config_json from "./cal-ai/config.json";
import { metadata as caldavcalendar__metadata_ts } from "./caldavcalendar/_metadata";
import campfire_config_json from "./campfire/config.json";
import campsite_config_json from "./campsite/config.json";
@@ -100,6 +101,7 @@ export const appStoreMetadata = {
autocheckin: autocheckin_config_json,
"baa-for-hipaa": baa_for_hipaa_config_json,
basecamp3: basecamp3_config_json,
+ "cal-ai": cal_ai_config_json,
caldavcalendar: caldavcalendar__metadata_ts,
campfire: campfire_config_json,
campsite: campsite_config_json,
diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts
index b4fb5e6c65..9ba075ee3c 100644
--- a/packages/app-store/apps.server.generated.ts
+++ b/packages/app-store/apps.server.generated.ts
@@ -10,6 +10,7 @@ export const apiHandlers = {
autocheckin: import("./autocheckin/api"),
"baa-for-hipaa": import("./baa-for-hipaa/api"),
basecamp3: import("./basecamp3/api"),
+ "cal-ai": import("./cal-ai/api"),
caldavcalendar: import("./caldavcalendar/api"),
campfire: import("./campfire/api"),
campsite: import("./campsite/api"),
diff --git a/packages/app-store/cal-ai/DESCRIPTION.md b/packages/app-store/cal-ai/DESCRIPTION.md
new file mode 100644
index 0000000000..6c045ee7c0
--- /dev/null
+++ b/packages/app-store/cal-ai/DESCRIPTION.md
@@ -0,0 +1,32 @@
+---
+items:
+ - 1.jpg
+ - 2.jpg
+ - 3.jpg
+ - 4.jpg
+ - 5.jpg
+---
+
+{DESCRIPTION}
+
+## Example questions:
+
+- "Book a meeting with @cal-user this week at a good time"
+- "Find a time to meet with not-a-cal-user@gmail.com this week"
+- "Move my meeting with [Someone's Name] to next week"
+- "What does my week look like?"
+- "When is my next meeting?"
+- "What is my least busy day next week?"
+- "Cancel the meeting with [Someone's Name]"
+- "Reschedule the meeting with not-a-cal-user@gmail.com"
+- "Is @cal-user busy on Friday at noon?"
+- "Move my meeting at 2pm tomorrow to next week on Friday at 5pm"
+
+### Or, any of that in a thread:
+- What meetings do I have this week?
+ - Meeting 1, Meeting 2
+- Cancel that first meeting
+
+
+### Or, doing multiple things:
+- Cancel all my meetings tomorrow then book a quick chat with @joe at noon, a long meeting with @jane at 1 (both tomorrow). Also see if Toby is down to grab dinner this week (toby@gmail.com). Finally send me my updated agenda of everything I have coming up in the next couple days.
\ No newline at end of file
diff --git a/packages/app-store/cal-ai/api/_getAdd.ts b/packages/app-store/cal-ai/api/_getAdd.ts
new file mode 100644
index 0000000000..d8e13972c6
--- /dev/null
+++ b/packages/app-store/cal-ai/api/_getAdd.ts
@@ -0,0 +1,26 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import { defaultResponder } from "@calcom/lib/server";
+
+import checkSession from "../../_utils/auth";
+import { withPaidAppRedirect } from "../../_utils/paid-apps";
+import appConfig from "../config.json";
+
+export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
+ const session = checkSession(req);
+
+ const redirectUrl = await withPaidAppRedirect({
+ appPaidMode: appConfig.paid.mode,
+ appSlug: appConfig.slug,
+ userId: session.user.id,
+ priceId: appConfig.paid.priceId,
+ });
+
+ if (!redirectUrl) {
+ return res.status(500).json({ message: "Failed to create Stripe checkout session" });
+ }
+
+ return { url: redirectUrl };
+}
+
+export default defaultResponder(getHandler);
diff --git a/packages/app-store/cal-ai/api/_getCallback.ts b/packages/app-store/cal-ai/api/_getCallback.ts
new file mode 100644
index 0000000000..e424d9bde7
--- /dev/null
+++ b/packages/app-store/cal-ai/api/_getCallback.ts
@@ -0,0 +1,65 @@
+import type { NextApiRequest, NextApiResponse } from "next";
+
+import { defaultResponder } from "@calcom/lib/server";
+import { createContext } from "@calcom/trpc/server/createContext";
+import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
+
+import checkSession from "../../_utils/auth";
+import getInstalledAppPath from "../../_utils/getInstalledAppPath";
+import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
+import { withStripeCallback } from "../../_utils/paid-apps";
+import appConfig from "../config.json";
+
+export async function getHandler(req: NextApiRequest, res: NextApiResponse) {
+ const session = checkSession(req);
+ const slug = appConfig.slug;
+ const appType = appConfig.type;
+
+ const { checkoutId } = req.query as { checkoutId: string };
+ if (!checkoutId) {
+ return { url: `/apps/installed?error=${JSON.stringify({ message: "No Stripe Checkout Session ID" })}` };
+ }
+
+ const { url } = await withStripeCallback(checkoutId, slug, async ({ checkoutSession }) => {
+ const ctx = await createContext({ req, res });
+ const caller = apiKeysRouter.createCaller(ctx);
+
+ const apiKey = await caller.create({
+ note: "Cal.ai",
+ expiresAt: null,
+ appId: "cal-ai",
+ });
+
+ await checkInstalled(slug, session.user.id);
+ await createDefaultInstallation({
+ appType,
+ user: session.user,
+ slug,
+ key: {
+ apiKey,
+ },
+ subscriptionId: checkoutSession.subscription?.toString(),
+ billingCycleStart: new Date().getDate(),
+ paymentStatus: "active",
+ });
+
+ await fetch(
+ `${process.env.NODE_ENV === "development" ? "http://localhost:3005" : "https://cal.ai"}/api/onboard`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ userId: session.user.id,
+ }),
+ }
+ );
+
+ return { url: getInstalledAppPath({ variant: appConfig.variant, slug: "cal-ai" }) };
+ });
+
+ return res.redirect(url);
+}
+
+export default defaultResponder(getHandler);
diff --git a/packages/app-store/cal-ai/api/add.ts b/packages/app-store/cal-ai/api/add.ts
new file mode 100644
index 0000000000..912126e232
--- /dev/null
+++ b/packages/app-store/cal-ai/api/add.ts
@@ -0,0 +1,5 @@
+import { defaultHandler } from "@calcom/lib/server";
+
+export default defaultHandler({
+ GET: import("./_getAdd"),
+});
diff --git a/packages/app-store/cal-ai/api/callback.ts b/packages/app-store/cal-ai/api/callback.ts
new file mode 100644
index 0000000000..07586cf85f
--- /dev/null
+++ b/packages/app-store/cal-ai/api/callback.ts
@@ -0,0 +1,5 @@
+import { defaultHandler } from "@calcom/lib/server";
+
+export default defaultHandler({
+ GET: import("./_getCallback"),
+});
diff --git a/packages/app-store/cal-ai/api/index.ts b/packages/app-store/cal-ai/api/index.ts
new file mode 100644
index 0000000000..eb12c1b4ed
--- /dev/null
+++ b/packages/app-store/cal-ai/api/index.ts
@@ -0,0 +1,2 @@
+export { default as add } from "./add";
+export { default as callback } from "./callback";
diff --git a/packages/app-store/cal-ai/components/AppSettingsInterface.tsx b/packages/app-store/cal-ai/components/AppSettingsInterface.tsx
new file mode 100644
index 0000000000..8c40985f6e
--- /dev/null
+++ b/packages/app-store/cal-ai/components/AppSettingsInterface.tsx
@@ -0,0 +1,73 @@
+import { useSession } from "next-auth/react";
+
+export function MailLink({ subject, body }: { subject: string; body?: string }) {
+ const { data } = useSession();
+
+ return (
+
+ {subject ? subject : body}
+
+ );
+}
+export default function AppSettings() {
+ return (
+ <>
+
+
Example questions:
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
Or, any of that in a thread:
+
+ -
+ What meetings do I have this week?
+
+
+ - Cancel that first meeting
+
+
Or, doing multiple things:
+
+
+ >
+ );
+}
diff --git a/packages/app-store/cal-ai/config.json b/packages/app-store/cal-ai/config.json
new file mode 100644
index 0000000000..c13c045312
--- /dev/null
+++ b/packages/app-store/cal-ai/config.json
@@ -0,0 +1,23 @@
+{
+ "/*": "Don't modify slug - If required, do it using cli edit command",
+ "name": "Cal.ai",
+ "slug": "cal-ai",
+ "type": "cal-ai_automation",
+ "logo": "icon.png",
+ "url": "https://cal.ai",
+ "variant": "automation",
+ "categories": ["automation"],
+ "publisher": "Cal.com, Inc.",
+ "email": "support@cal.com",
+ "description": "Cal.ai is your AI scheduling assistant. Get your personal email assistant (username@cal.ai) that you can forward emails to or have a conversation with. Cal.ai will automatically schedule meetings for you.",
+ "isTemplate": false,
+ "__createdUsingCli": true,
+ "__template": "basic",
+ "dirName": "cal-ai",
+ "isOAuth": false,
+ "paid": {
+ "priceInUsd": 8,
+ "priceId": "price_1O1ziDH8UDiwIftkDHp3MCTP",
+ "mode": "subscription"
+ }
+}
diff --git a/packages/app-store/cal-ai/index.ts b/packages/app-store/cal-ai/index.ts
new file mode 100644
index 0000000000..d7f3602204
--- /dev/null
+++ b/packages/app-store/cal-ai/index.ts
@@ -0,0 +1 @@
+export * as api from "./api";
diff --git a/packages/app-store/cal-ai/package.json b/packages/app-store/cal-ai/package.json
new file mode 100644
index 0000000000..3cde71d537
--- /dev/null
+++ b/packages/app-store/cal-ai/package.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "private": true,
+ "name": "@calcom/cal-ai",
+ "version": "0.0.0",
+ "main": "./index.ts",
+ "dependencies": {
+ "@calcom/lib": "*"
+ },
+ "devDependencies": {
+ "@calcom/types": "*"
+ },
+ "description": "Cal.ai is your AI scheduling assistant. Get your personal email assistant (username@cal.ai) that you can forward emails to or have a conversation with. Cal.ai will automatically schedule meetings for you."
+}
diff --git a/packages/app-store/cal-ai/static/1.jpg b/packages/app-store/cal-ai/static/1.jpg
new file mode 100644
index 0000000000..9792aedc37
Binary files /dev/null and b/packages/app-store/cal-ai/static/1.jpg differ
diff --git a/packages/app-store/cal-ai/static/2.jpg b/packages/app-store/cal-ai/static/2.jpg
new file mode 100644
index 0000000000..050acf433f
Binary files /dev/null and b/packages/app-store/cal-ai/static/2.jpg differ
diff --git a/packages/app-store/cal-ai/static/3.jpg b/packages/app-store/cal-ai/static/3.jpg
new file mode 100644
index 0000000000..fab5b1ef18
Binary files /dev/null and b/packages/app-store/cal-ai/static/3.jpg differ
diff --git a/packages/app-store/cal-ai/static/4.jpg b/packages/app-store/cal-ai/static/4.jpg
new file mode 100644
index 0000000000..0b0fbbfde9
Binary files /dev/null and b/packages/app-store/cal-ai/static/4.jpg differ
diff --git a/packages/app-store/cal-ai/static/5.jpg b/packages/app-store/cal-ai/static/5.jpg
new file mode 100644
index 0000000000..ccb8475bdd
Binary files /dev/null and b/packages/app-store/cal-ai/static/5.jpg differ
diff --git a/packages/app-store/cal-ai/static/icon.png b/packages/app-store/cal-ai/static/icon.png
new file mode 100644
index 0000000000..c63758d416
Binary files /dev/null and b/packages/app-store/cal-ai/static/icon.png differ