{
  "video_id": "HUfZNPzI-rw",
  "title": "Build and Deploy a B2B SaaS AI Support Platform | Next.js 15, React, Convex, Turborepo, Vapi, AWS",
  "url": "https://www.youtube.com/watch?v=HUfZNPzI-rw",
  "transcript": "What if your support platform could talk to your users, carry full conversations over voice or chat, detect frustration, escalate when needed, and resolve issues automatically? What if the AI could learn your product by reading your documentation? No fine-tuning, just real answers grounded in your content. That's exactly what we're building. This is Echo. A production-grade AI support platform powered by Convex, Vapi, Clerc, AWS, and more. With support for any LLM provider you choose, including Gemini, OpenAI, Anthropic, or Grok. All built inside a Turbo Repo with not one, not two, but three separate apps. An operator dashboard, a chat widget, and a developer toolkit for embedding the experience anywhere. This is part 2 of the build, and we're going deeper. Document embeddings, retrieval augmented generation, AI search, secure pertinent credentials, billing, and more. Everything you need to turn Echo into a real B2B SaaS product. And now, without further ado, let's get started. In this tutorial, we're using Sentry for full-stack error tracking, real-time alerts, session replays, and deep visibility across both front-end and back-end. If you want to follow along or plug it into your own app, use the link in the description to get three months of Sentry Team completely for free. Now, let's dive in. In this chapter, we're going to learn how to generate embeddings using convex RAG or retrieval augmented generation component. Just before we jump into that, I want to fix one little oversight from the previous chapter. And that is, if a conversation is unresolved and the operator decides to send a message in the middle of the conversation between the AI and the customer, we should forcefully change that status to be escalated to automatically turn off AI. So let's quickly do that. Inside of packages backend convex let's go inside of private messages and when the message gets created which will be by the operator in here I think we already check if the status is resolved and we throw the error. So now let's check if the conversation status is unresolved. And if it is unresolved, we're going to force the conversation ID to change the status to escalate it. And let me just use the arguments.conversationID here. So why am I doing this? Well, if you take a look at the messages in the public convex folder and find create here, you will see here that we check if the conversation status is unresolved. And only if it's unresolved do we actually trigger AI response. Otherwise, we just save the user's message. So that's why we're doing that. So if the user intercepts here, we are switching the status to escalated, meaning, all right, the operators, basically wanted to interrupt the conversation between the AI and the user. It means the operator wants to talk to the user directly. This means this conversation has escalated. The AI is no longer handling this case. So that's why I wanted to add this only if the current status is unresolved. Great. And now what I want us to do is I want us to learn how to create embeddings using convex. So let's start by creating the files functions. So we're going to go inside of packages, backend, convex, private, and let's create files.ts. And inside of here, let's start by creating a very simple add file, which will be an action. Now the arguments here are going to be file name, which is a string. I never knew how to pronounce this mime type I guess which is a string bytes which will be a type of bytes and category which will be optional and a string category will help with embedding and making it more specific. Now let's add our handler here. Let's import the convex values. So we get rid of those errors. Let's extract the context and the arguments from here. And even though this is an action, we can still actually verify our identity. So let's quickly steal that from messages, for example. We check the identity and we check for the organization ID. So let's copy this and let's add it here. So like this and import the convex error from convex values. So we can still get the identity and they can still get the organization ID. Great. Now that we have this, let's go ahead and let's destructure bytes, file name and category from our arguments. Now let's go ahead and let's determine the MIME type. So that's going to be arguments.mimetype or let's use guessMIME type, which will be a method we're going to create now. This method will accept the file name and the bytes. Let's go ahead and let's create the guessMIME type method. So here at the top, I'm going to create a function, guess mime type. The file name will be a type of string and the bytes will be a type of array buffer. And it will return back a string. Now inside of here, we're going to have to import our convex dev rag component. But if you try adding that now, it's not going to work because we didn't add it. Let's try. So if I add guess mime type from contents from at convex dash dev, you can see we only have agent. We don't have rag, which is the component we need. So let's learn how to add it. Instead of your agents here, you have rag. Let's go ahead and let's add it here. So install the rag component. Let's go ahead and click here so we'll learn how to do it. let's go ahead and run convex dev rag so I'm going to do pnpmf backend add convex dash dev forward slash rag and I'm also going to show you the exact version that got installed here you can see how now we no longer have this error and let me just show you so 0.3.3 is my version but we're not done yet so after we install it we have to go inside of convex.config.ts so let's go ahead inside of convex.config.ts here let's go ahead and let's import rag the same way we imported the agent and then let's add app.use rag just like that and i believe that's it for now. Great. So now we can follow this thread going forward. But let's go back inside of files now. And now we have this guess mime type from contents, but that's not going to be the only thing we're going to import here. We're also going to import guess mime type from extension. So now let's go ahead and return open parenthesis guess mime type from extension and pass in the file name. If that's not available, let's do guess mime type from contents and pass in the bytes. And if that's not available, we're just going to label this as application. And again, not sure how to pronounce this, octet stream. Let's go ahead and end this function. So that is our guess mime type function. And now down here we have the mime type which will be a string. So either the one we pass from the arguments or the one from here. And I think we could also extract the mime type here and then we don't. Oh yeah let's not do that because this is called mime type. Now that we have the mime type let's define the blob here. New blob. Open an array and add the bytes inside and give it a type mime type. Now let's go ahead and let's store the file. So cons storage id await context storage store and pass in the blob. It is actually that easy to store an uploaded file from Convex. So yes, Convex has built-in file storage, if you didn't know. You can go and click upload here and you can see different ways of uploading. So one way you can do it is by using generate upload URL. You can do it using actions like we're doing. You can also do it using HTTP actions, basically a bunch of options that you can do. Let's go ahead and continue developing here. Now what we have to do is we have to extract the text content from whatever file we just uploaded. And the way we're going to do that is by defining constant text await. And then we're going to create a new method extract text content. And it's going to accept the context. And then in its second parameter, it will accept the storage ID, file name, bytes, and mime type. Now let's go ahead and let's develop this component. So this one will be quite large. So I don't want to write it in this file. Instead, I'm going to go inside of convex folder and I will create a new folder called lib. And inside I going to create extract text content Again remember instead of convex we have to use camel case Is this called camel case I think it is. Yeah. And let's go ahead now and let's import openAI from AI SDK openAI or whatever it is that you use. So let me just find this. so in my case you've already done this inside of messages private inside of support agent right whatever you use here gemini or whatever else you used now let's also import generate text and let's import i mean generate text from ai that's it let's go ahead and import type storage action writer from convex server let's import assert from convex helpers convex helpers is actually a super useful little library i highly advise that you search for it so this assert will basically help us validate truthness at runtime providing a type card you're going to see what it is in a second so yes if you have a constant that is string or null you can just run assert x and from that line and below x it will be definitely a string so it's super useful when writing ellipse like this let's create a factory of ai models that we're going to use to create embeddings and to extract text from uploaded files so if we receive an image i'm going use openai.chat and I'm going to pass in gpt4o mini. For you this would be I mean if you're using something else I think this is the same thing I've been using everywhere so yeah whatever you used in your support agent or your messages just use it again here for the image. For pdf openai.chat I think you can use the same thing I'm going to use 4.0 because it's better at extracting text from PDFs. But I'm pretty sure it would work exactly the same if it was 4.0 mini. So if you're not sure what to use for your AI model, you can just use the same thing. If we receive HTML file, chat GPT 4.0. And let's add as constant. Now let's go ahead and let's write the supported image types. so why are we supporting this so we basically want to allow the user to upload whatever they want if they have an image of some text we want to allow them to do that if they have a pdf sure html whatever you can even do audio if you want to the only reason i'm not doing audio here is because i'm not sure if the model you're using has transcribe function not all models have them so i'm only doing what all models have so the supported image types are jpeg png webp and gif and let's also add as const here and now let's go ahead and define const system prompts so for image we're going to say you turn images into text if it is a photo of a document transcribe it if it is not a document describe it PDF, you transform PDF files into text. HTML, you transform content into markdown. So we are basically telling the AI model what to do if it receives an image, what to do if it receives PDF, and what to do if it receives HTML. Now let's export type extract text content arguments. We're going to have storage ID, which is a type of ID from generated data model. And it's going to be a type of underscore storage. File name will be a type of string. Bytes will be an optional array buffer. And MIME type will be a type of string. and now let's export asynchronous function extract text content. The first argument it's going to accept is context, and that's going to be an object which has storage in it, storage action writer. This is the type that we imported from Convex server. The second argument will be arguments, and those will be extract text content arguments. So how did I know these are the ones that are going to be inside? Well, remember, I'm the one calling this function and I'm passing storage ID, file name, bytes, and mime type. And I'm passing context. But the only thing I will need the context for is for the storage. So that's what I'm giving it the types for. It doesn't need to have the other types. So in here, I'm just translating what I wrote here. Now that we have that, let's go ahead and define the return type. It's going to be a simple promise which returns a string. Now let's go ahead and let's destructure all of those things from our arguments here. Storage ID, file name, bytes, and MIME type. Let's go ahead and let's get the URL. We can get the URL of the file by calling await context storage, because remember just moments ago we stored that file inside of our file storage and now convex gives us a super easy way to turn that storage id into actual url that we can access so get url storage id so yes just by having the storage id file you can't do much you need to use their get URL to actually turn it into the URL. So super useful file upload thingy. And here's the thing, yeah, so URL can be null. And now that will be a little bit problematic to work with. So thanks to assert from convex helpers, we can just run assert URL, and then in the second argument, the error to throw. Failed to get storage URL. And if you try and type URL from now, so let's just do const a URL, you will see that URL is always a type of string. But before this, it was string or null. I think these kinds of things are super useful and convex helpers are full of things like this. So make sure to Google convex helpers and you will find much more about that than what I did in this tutorial. let's check if we uploaded a supported image type or not so you've supported image types some type type is equal mime type let's go ahead and return extract image text and pass in the url and now let's go ahead and develop extract image text to extract the text from an image So asynchronous function, extract image text, will accept URL, which is a type of string. And just like that, the errors are gone. The return will be a promise string. Let's go ahead and let's do const result, await, generate text. And inside of here, the model will be AI models.image. System will be system prompts.image. So let's take a look. We are using the AI method from our AI package. And we are using OpenAI Chat GPT-40 mini in my case, because it's good at describing images or reading the text from images. You would use the equivalent of for Gemini, whatever it is, the base model. You can just try whichever is the best one, right? You will see if it works or not. And once you have that, we also choose the prompt. and we give it, you turn images into text. If it is a photo of a document, transcribe it. If it is not a document, describe it. So that's what we are doing here. And then let's add messages here. Role, user, content, type, image, image, new URL, URL. And the AI will basically read this and it will give us result text back. And just like that, we develop extract image text. And now every time user uploads an image, we can extract or describe the transcript. Now let's handle PDF files. If mime type to lowercase includes PDF, In that case, let's return extract PDF text, passing URL, MIME type, and file name as the third argument. Now let's develop extract PDF text function. So asynchronous function, extract PDF text. And let's open this. we're going to have URL which is a type of string MIME type which is a type of string and add commas like this not semicolons and file name which is a type of string and we are going to return a promise which returns a string and again const result is a way to generate text model will be whichever model we define to be used for PDF documents the system prompt will be whatever system prompt we define for PDF documents. Messages will be similar. Role, user, and then content inside will be the following. The first content will be a type of file. Data will be new, URL, pass in the URL, pass in MIME type, pass in file name. So we give as much context as possible to the system model. And then let's go ahead and add type text. Text, extract the text from the PDF and print it without explaining you'll do so. So we're just adding some further instructions specifically here because this will make the embeddings clearer because sometimes you can exactly control AI models and they will add more tokens in the embedding that it needs to have and then your ai model might give some wrong information so that's why we are adding some additional instructions here and then just do return results result.text there we go now we have extract pdf function and now let's go ahead and handle all the other text-based files so if mime type to lowercase includes text let's return extract text file content passing context storage id bytes and MIME type. And just like we've developed the other ones, let's develop the extract text file content here. So asynchronous function, extract text file content. Let's go ahead and add context here and let's give it a type of storage, storage action writer. Then let's go ahead and give it storage ID, which is a type of ID underscore storage. After that, bytes, which is a type of array buffer or undefined. After that, mime type, which is a type of string. And as always, we are returning back a promise, which resolves to a string. Now, let's go ahead and let's first define the array buffer. This is going to be bytes or let's open parentheses await open parentheses again await context storage get storage ID. And then inside of here question mark dot array buffer and execute that. And you can see that now we have array buffer or undefined returned. So basically, we are either using the bytes that we are able to pass in if we got it through file upload, or we're going to grab the storage file and attempt to run array buffer on it. Either way, it's either going to be array buffer or undefined. So if we are not able to get the array buffer, let's go ahead and throw new error here, failed to get file content. now let's go ahead and do const text new text decoder decode array buffer now let's check if my type to lowercase is not text plain in that case let's run the ai model so what just happened here basically we need to when there are two ways we can extract text there is a text files are fairly simple so you can do that just by using the text decoder and the array buffer right but what if you receive something that's technically considered a text file in the mime type world but isn't as simple as text plain like markdown files Well, in that case, we need to call await generate text. Why didn't I do it in here? Well, if possible, we don't have to use AI, right? And our goal as a business is to save money if we know how to do so. In this case, we know how to extract text from a very simple text file using the array buffer. but just in case if it so happens that it's not text plain so it's a markdown or something more complicated like html maybe let's go ahead and do the following result is equal to await generate text and then model ai models dot html system system prompts dot html messages the first one coming from the user content the first content type text and text in here and then we need to further describe this so type text text extract the text and print it in a markdown format without explaining that you'll do so. Like that. And then return result.text. Otherwise, simply return the text from the decoder. This way, we are able to parse normal text files using the decoder, but also more complicated text files like HTML or markdowns. So we can still use the text decoder, but we need to analyze this more thoroughly by using AI. So we are giving the AI the content from the text decoder and then go ahead and do it even further. So we have the proper embedding. And now this is working, this is working, and this is working. let's go ahead and handle the last type which is the unsupported type so throw new error unsupported mime type and let's render the mime type this way we will know if user attempts to upload something that we don't support and we're going to get that inside of convex logs and inside of sentry logs as well because our convex and sentry are connected and you will be able to get the bottom of it by disabling that mime type from being uploaded because it's not something we can handle right now. So if you've done this correctly you shouldn't have any errors in this code. Again if you're unsure what to use here just use whatever model you've used so far and then you will see if it works or not. I recommend that you try this knowledge-based thing and the embeddings with very simple text files because they don't actually require AI. So you can just do the text decoder and it will work because it won't even have to use generate text. Perfect. We now have the extract text content file and now let's go ahead and import extract text content like this from lib extract text content. And now that we have the extract text content, we have to add those embeddings. So we now have the text as you can see. Basically whatever the user uploaded we have it in textual form. So now let's go ahead and do await rag dot add and I didn't import rag so let's go ahead and just quickly do that. In order to add rag you actually have to set it up so I didn't follow through the documentation entirely so let's do that. Basically instead of convex instead of system, instead of AI. The same way we had the agents, now let's create a new file called rag.ts. Inside, let's go ahead and import your model again from your AI SDK and then whatever provider you are using. Then go ahead and add rag from convex dev rag and then import components from generated API. Go ahead and define a rag using new rag components.rag. Text embedding model. Go ahead and add openai.embedding. And now, what if you don't know what is your embedding model for Gemini, for example? Well, you can go to AI SDK. And yeah, by the way, in the middle of me making this tutorial, SDK version 5 was actually released. I'm not sure if it's fully compatible with the current convex versions. So I would suggest that you actually use the exact version that I'm using or at least be on version 4 simply so you avoid any problems. But if you were been using version 5 so far and everything was working, no problem, no need to change it. And same thing goes for... Let me just see. AI SDK OpenAI. Same thing goes for this. I think the versions kind of need to be similar here. So what if you don't know what is your embedding model? So you go inside of providers on AI SDK and you select your provider. In my case, it's OpenAI. And if I scroll down here, I think that I can find the embedding models. And in here, you can see the embedding models that I have, right? And for example, if you're using Google Generative AI, which I think it's Gemini, same thing. Scroll down and find if you have the embedding models. You do, perfect. and then just use Gemini Embedding 001. I think that by default, when you do OpenAI Embedding, you should see all the options here. So if you're using Gemini and click Gemini.Embedding or Google.Embedding or whatever else you're using, type safety heel will help you. So I will use text embedding 3 small. Now we have a slight problem. Components.Rack doesn't exist because we don't have our backend convex dev running. so just make sure you do through both dev so you have convex dev running this will analyze the source code it will see our new um rag component and let's just see uh okay so components.rag now works but this is still incorrect so let's finish it embedding dimension i'm going to add one five three six so i think you can do exactly the same i think this is the same for all embeddings so it doesn't matter what model you are using and let's export default rag you can read more about that settings in the actual convex agents rag and then in here you can find out exactly what it means let me try and find it somewhere okay they have more in their rag example basically definitely research through this documentation here now that we have the rag component we can go back inside of files which we started developing and now we can import that rag from system ai rag in here let go ahead now and do the following so rag pass in the context and then pass in the namespace The namespace will be where are these embeddings getting saved Think of it like row level security for the user who is currently logged in, right? Who should be able to access these embeddings? Because it will be quite dangerous if you don't pass the namespace, right? Namespace basically tells you, I should teach our AI something, but for whose organization should I teach that? Think of it like that. So it's super important that you pass in the organization ID here. So only the user who is currently logged in and has a specific organization attached to them should be having these embeddings. It shouldn't be global. Imagine it's a super secret file. You only want this to be visible for that organization namespace, right? Or imagine some random person can upload a file and then all of your customers suddenly have weird answers from your chatbot because it was thought that. So that's why namespace is super important to have. So let's add a little comment explaining that. Super important. What search space to add this to? you cannot search across namespaces and let's also do if not added it will be considered global which is something we do not want we do not want this add these comments you know what this is about after that let's add the text let's add the key to be file name. Let's add the title to be file name. And now let's add metadata. The metadata will have the storage ID so we can retrieve that file if needed. Upload it by if needed as well. But let's add organization ID here. File name again in the metadata and category will be category or null since it is optional. And then let's add content hash, which will be await content hash from array buffer and pass in the bytes. Why do we need this? We need this to avoid reinserting if the file content hasn't changed. so if we upload the exact same file and we compare its hash from the array buffer this and we notice that it is the exact same file we won't reinsert that into the embedding so this is the exact line and comment from the convex example most of this actually is right from their rag examples here so if you see that my comments here are exactly the same as their example series because of that. I am teaching you exactly the way they teach to do this. Great. So I think this is pretty clear what's happening, right? We basically had to extract text from various types of files that can be uploaded. And then we are creating a beddings using their rag.add. We are adding it to a certain namespace so that it cannot be searched by other organizations and then we are adding some metadata so that it's easier for us to display how this will look like later now from here let's go ahead and let's extract entry id and create it and now let's go ahead and check if not created meaning something went wrong let's console.debug entry already exists skipping upload metadata. And let's just do await storage context.storage delete storage ID. Like this. And finally, let's return. The URL of the file we just uploaded will be await context storage get URL storage ID. and entry ID. There we go. That is our add file function. Now, let's go ahead and let's implement delete file function. Instead of being an action, delete file is going to be a mutation. So, export const delete file will be mutation. let's go ahead and add arguments here. Let's add the handler. Now let's define the arguments inside. Let me just replace this with a comma. The arguments are going to be very simple. We're going to be looking for entry ID and that's going to be a type of vEntryId. You can import this exact type from convexDevRag. That's the only thing we're going to need. After that, extract the context and the arguments from here. And now let's protect ourselves like we usually do by searching for our identity and the organization ID. So we can copy that from the add file action here. So make sure this exists. And of course, let's import mutation from generated server. So we don't have those errors anymore. After the user has confirmed their identification and their organization ID, let's go ahead and attempt to see if we have permission to actually delete this embedding how can we do that well by searching for the namespace because remember our embedding is stored under our organization namespace which besides meaning that we are the only ones who can search for it it also means we are the only ones who can delete it so const namespace await rag get namespace context namespace organization id. If there is no namespace let's go ahead and throw new error unauthorized. In fact we can throw this and let's say invalid namespace. We don't have permission to delete or access this entry in the first place. now let's attempt to get the actual entry now that we know we have access to this namespace by doing await rag get entry and pass in the context entry id arguments entry id if there is no entry it means this doesn't exist so let's go ahead and throw a new convex error here not found and let's simply say entry not found now let's check one more time if we have access to do this by using our metadata uploaded by so entry dot metadata question mark uploaded by be careful here this is not strictly typed so uploaded by is different from the organization id in that case throw new convex error again and pass in the code unauthorized and the message invalid organization ID. So this is just the last fail safe because even though we confirmed our namespace, just in case something goes wrong and we just got this entry, we still have the entry metadata. If you remember in the add file, We add metadata, which can be whatever, literally. This can be whatever you want. And the one thing we added is uploaded by. So we upload on the level of organization. So only that organization should be able to delete this file. So this is just the final check. This also needs to match. So this is also important for deletion. Mark that here. Make sure you didn't misspell uploaded by. And make sure that when you highlight it down there, it's highlighted up here so you know it's written exactly the same in the exact same capitalization. Make sure you're using the reverse value here. So if it is not equal, throw the error. And then finally, let's go ahead and check. If entry metadata question mark storage ID is present, what does this mean? So remember, when we upload a file, the first thing we do is we upload it to the storage. and then we create the embedding. So those are two separate systems. If you head to your Convex dashboard, you will see that right here. Instead of my Echo tutorial here, you can see that I have my files. So my files are not my embeddings. They are just files. I can literally add them from here. That will not create embeddings. But if I go inside of my data app and click on rag, you will see that this are the embeddings. So we need to delete both when the user requests deletion of a file that is tightly coupled with the rag. So what we have to do instead of our delete file here is check if we have the storage ID, which we also kept in the metadata. Make sure you are passing it here. So also important for file deletion. Make sure you have passed that here. If we have it here, let's go ahead and do await context storage dot delete entry metadata storage ID as ID underscore storage. And you can import ID from generated data model. so once you've done that you are finally ready to await and do rag delete context entry id arguments entry id and looks like this is deprecated oh okay use delete async in mutations delete async. Let's go ahead and do it like this. Perfect. So that is how you delete an embedding. This is equally as important, right? We don't want to infinitely fill our embedding and not be able to delete that embedding. This way we tightly couple them together and we keep track of absolutely everything. Great. So we\nWe can't really test this, but what I want you to know is that if you have your convex functions ready, there's a 99% chance that you did everything correctly here. And you should also have the rag available here, right? So if you have it available, it means that your rag component was successfully added, and it most likely means that you added the correct embedding model and everything. So even though in here I have added to add list files, I'm not sure how much sense this makes right simply because it's a long function and I think we've done enough for this one chapter and it will make more sense to create list files along creating the UI to render the list files so I will actually leave this for the next chapter for that purpose because we already did a lot so amazing job you learned how to generate embeddings using the retrieval augmented generation component from convex. Now let's go ahead and merge all of these changes. So 20 generating embeddings. Let's go ahead and stage all of these. Let's do 20 generating embeddings. That is the name. Commit. I'm going to go ahead and create a new branch. 20 generating embeddings. And I'm going to publish this branch. And I think this will be quite interesting to see CodeRabbit review. Very interesting to see what it thinks of our code to extract text, what it thinks of our security measures. Let's wait and see. And here we have the summary by CodeRabbit. We added support for uploading, indexing, and deleting files within organizational namespaces, including extraction of text content from images, PDFs, and text files for improved search and retrieval. We integrated a retrieval augmented generation system to enhance AI-powered document search and embedding capabilities. We improved conversation handling by automatically escalating unresolved conversation when a new message is added from the operator side. So let's go ahead and take a look at how file upload works actually. That should be quite the file and passes in the file name, bytes, and mime type. Then let's go ahead and see what happens here. So once the add file adds this file to storage, what we do is we analyze the file by calling the extract text content. We determine is it image, is it PDF, or is it text, and then we return the text back. I think for this exact example, when it differs between return extracted text or just return text is the specific example of do we have just text plain or is it a more complex text model and once the extraction of the text is complete we add the entry using the rag component we confirm the entry via backend and then we return back the final url and the entry id and we already know the escalation method here so let's look at the comments here in here it suggested improving security validation and add support for more mine types so yeah right now we only actually limit the types of images that you can upload i don't think we explicitly limit the types of files you can add so yeah this could definitely be a good idea to limit the files that we can add because I mean we already throw an error if there's an unsupported mime type but yeah maybe it could be a good idea to add them here and then throw before we'll see yeah I will consider this definitely in here it tells us to improve the error message so that we also mark the storage id so he knows which storage id is failing that's a good idea yes for production that would definitely help us so we know exactly what keeps failing rather than just fail to get file which file right good idea in here it mentioned a potential issue and i think i i kind of thought of this when i was writing this because yes if the file wasn't created we debug with entry already exists skipping upload metadata and then we delete the storage file here. So this is from the example from convex rags. So I'm not 100% sure why we're doing this, but I have to assume it is so we don't have any duplicates here. That's the reason I think we're doing this. So perhaps we could either do an early return here, or we could just not return the URL at all. We'll have to see the next chapter, our front-end implementation and the upload dialogue to see do we even need this upload file because this is problematic right if we just remove the storage this will be an error I believe so yes that's what it's warning us about here definitely correct here and in here interestingly yes it's telling us to consider atomic status update to prevent race conditions I think there's several ways we can actually fix this so here's a way they suggest they suggest adding a new retrieval just before we patch this. So good idea. But we could also schedule this update for running in a separate thread, I believe, by using convexes scheduler. I think we can do that. And then that will fix the transaction issue. I think that's the equivalent of transaction. Not 100% sure, but I think it is. I will consider this. Basically, you can find it here, scheduling, scheduled functions. Let's see, this allows you to build powerful durable workflows without the need to set up and maintain queues. I think this could be used for that. I'll research if that's the correct way to do that. So yes, this could be a problem if you have a very active application and a lot of things are happening with this conversation. It could run into a transaction error or a race condition there is also a question of how convex handles this in the first place right so i can't tell you with confidence if this comment makes sense or not but definitely good idea that we have to think about this uh in here it's actually teaching us a little bit about embedding dimensions here so yeah i have no idea what embedding dimensions are to be honest and you can see how it knows exactly why i added this number which i didn't know i just used the rag example but in here it knows that it is because I'm using this model so what does that mean for you well I actually found out that if you go instead of ai-sdk select your provider find embedding models if you scroll down you will see that you actually have some default output dimensionality here so you can see depending on what you use you can see the dimensions that you can put inside even though it says it supports custom dimensions so I have to assume you can add whatever number you want inside but if you're having problems in the next chapters consider changing your dimensions to one of these numbers depending on the model you have added. So what does that mean? When you go inside of your rag.ts here depending on what model you have here find it here and find the default dimensions. Let's see if that's true. Instead of my open AI If I go inside of my embedding models, yeah, you can see if I use text embedding three small, default dimension is 1536, the exact one I put here, which definitely means that I can make it optional. No, it does not. Okay. But that's why I put that number here. Okay. See, CodeRabbit is amazing. I would have no idea what I was doing. Great. So now I know how to add that, the number of dimensions. So what should you do? because we didn't really test this out whether this works or not. And I can pretty much put any number here, right? Find your provider. So whether that's Grok, whether that's DeepSeek or Google Gemini, I assume most of you will use because it's free. So Gemini has this amazing free model. If you didn't know, that's why I keep mentioning it. It just so happens that I like using OpenAI. I have their API keys and I have the billing, I have the credits, so I prefer using it. And it's a reliable model for me. So if you're using Gemini, head into embedding models, scroll down here, find the model that you have put here, and then find the default dimensions. And then once you merge this pull request, don't do it here. So later, when you merge this pull request, change this. I will remind you in the next chapter. I will try my best to remember as well. Perfect. So super helpful comment by CodeRabbit here to help us understand why we even added that. let's go ahead and merge this pull request now and then let's go back inside of the main branch let's click synchronize changes let's click okay let's go inside of our source control wait for the changes to synchronize let's go ahead and open our graph and in here you can see how this looks like so we merged 19 now we were in 20 and we merged that back here i believe that marks the end of this chapter. Amazing, amazing job and see you in the next one. In this chapter, we're going to continue working on our knowledge base, which we started in the previous chapter by learning how to generate embeddings. So in this chapter, we're going to start by creating our files.list function and then we're going to create the UI. That's going to be this knowledge base table right here and then we're going to create this upload files dialog as well. We're also going to enable the UI functionality to delete files. Let's start this chapter by double checking the embeddings dimension number. If you remember in the previous chapter when I ended the chapter, CodeRabbit left a comment here to standardize my embedding dimensions and this actually let me think, did I tell you the correct information to use the same number as I did? Because I'm using OpenAI and I don't know what you are using. So I asked it, where can I find info on other embedding dimensions? And it basically gave me all of these options. And it actually gave me a pretty comprehensive snippet here. So if you're using OpenAI, you're using the same number as me. If you're using Google, you should use this number, apparently the same as me and this one. If you're using Grok, it's another very specific number. And for example, if you're using Anthropic, you don't even have embedding models available. So what I want you to do to start this chapter is just double check that you using the correct embedding number Let go inside of our packages backend convex and let go inside of system AI rag. In here find the model that you're using and your provider and you can either use well this snippet that CodeRabbit generated for us to find your dimension or head to AI SDK. You can see the link on the screen, find your SDK provider here. For example, Google Generative AI, scroll down, find embedding models here. And when you scroll a little bit down, you will find what default dimensions are for what model. So depending on what you're using, make sure that you are adding the proper dimensions here. So don't just blindly use the same model, the same number as me like that great after you have established that let's go ahead and focus on files.list and we can double check this once again when we actually add the search tool to see if our embeddings work or not now let's create the files.list function so we're going to go inside of private files.ts. And at the bottom here, let's go ahead and do list like this. And let's go ahead and accept the following arguments. So we're going to accept the category if we ever want to query by category, and we're going to add pagination. So we can just import these from convex server, and we can import query from generated server, same as action and mutation. Now let's add the handler, which is an asynchronous method. And let's go ahead and add arguments and context. Whoops, opposite direction, context and then arguments. And first things first, let's check if we are logged in and if we have the organization. So I'm going to copy this and this. And I'm just going to paste it here. And then after we've confirm that the user is logged in and has a valid organization, let's go ahead and let's obtain the namespace. So const namespace will be await rag get namespace context namespace is organization ID. Now in case we couldn't find any namespace, let's go ahead and let's just return an empty query. So return page empty array is done. True. Continue cursor empty string. So this is basically the format that pagination returns. But in case we can't find the namespace, let's just throw an empty array. So we're just going to display nothing was found. Now let's go ahead and let's find the results from our rag. So const results await rag.list context namespace ID will be namespace from the constant above dot namespace ID. And pagination options will be arguments pagination options. So pagination is required here. And now let's go ahead and let's convert all of those files that we just found into readable format because these are entries from embeddings and now we have to convert those to files we can add in our table. Results.page.map and then for each entry that we found we're going to use our method convert entry to public file and pass in the context and entry. So by default this is just an error because we don't have this yet. Let's go ahead and quickly create it. Asynchronous function convert entry to public file. It's going to accept the context and this will be a query context type which you can import from here and you can actually use pick and just choose storage. That's the only thing we need and for the entry let's go ahead and use the type of entry you can import that from convex dev rag and let me go all the way down okay I will just scroll here great now let's go ahead and let's define the return method here so the return method will be a promise. And inside of this promise, we're going to define a public file. So let's export type public file from here. ID will be entry ID. We can import this from convex dev rag, or maybe we already had it imported already. Just make sure you have it. Name will be a string. So this will be what we are converting to, right? So name will be a string, type will be a string as well, size will be a string, status will be ready, processing, or error. URL will be string, or null. Category will be an optional string. And then you can use the public file type here as the return method. Now let's go ahead and let's also define the entry metadata while we are here. I think we don't have it here. Entry metadata. We don't. So let's do type entry metadata and let me just confirm. Is there any other place I might have used this already? Entry metadata. I have not. All right. Let's go ahead and then define it here. Entry metadata will have storage ID, which is id with a type of storage uploaded by which is a type of string file name which is a type of string category which is a type of string or null. We'll see if we need this for this method I'm pretty sure that we do but I also feel like we have written this type somewhere because this is the metadata type but I couldn't find it anywhere Let's see, maybe I called it something else in a different file. So let's go ahead now and continue developing our convert entry to public file here. And let's do const metadata, entry.metadata as entry metadata or undefined. Then let's define the storage ID here to be metadata question mark storage ID. so it's important that your metadata here matches exactly the way you store your file here right this metadata right here so if you want to maybe you can somehow add as entry metadata like this so you don't have any errors here and so that you know that everything works fine. I think that could be a good idea. Maybe add this to your rag.add because I don't think it will change the way this function works but I do think it will help you in showing any errors if you have something incorrectly written here. So this way I think this is a bit more secure. Now let's define the file size and by default it's going to be unknown. if we are able to find the storage id from the metadata let's go ahead and try and extract it so storage metadata is await context storage get metadata and that's in the storage id now let's see so this is now deprecated it says use database system dot get interesting. Let's see. So database, does it mean like this? Okay. Maybe I need the entire type here. Let's try database.system.get and then I just pass in the storage ID. Very interesting. I think that is the same thing. We'll see. So if I have storage ID, my apologies, if I have the storage metadata, I'm going to go ahead and do file size and let's go ahead and do format file size and pass in storage metadata dot size. Now we have to develop the format file size. So I'm going to do this just below here. function format file size accepts the bytes, which is a type of number and returns a string. If bytes is equal to zero, let's simply return zero bytes. Otherwise, let's define a kilobit here. Let's define sizes. let's define byte, kilobyte, megabyte, and gigabyte. Let's go ahead and find a constant i to be math.floor, math.log bytes divided by math.log divided by our k constant here. And let's go ahead and let's return inside of backticks number parse float open a function open another parenthesis inside bytes divided by k times i to fixed one and then let's go ahead and choose the size so this will be sizes i there we go so there's probably a library for this but inside it does the exact same thing as this. Now let's go ahead and once we have this file size, let's do the catch here. So if we have an error, let's console error failed to get storage metadata. And let's simply forward this error so we know what's going on. Now let's extract the rest of the file info. So const filename here is entry.key. Let's see, where do we have the entry? Here is the entry. All right, we should have it then. Entry.key or unknown. Then let's define the extension to be filename.split pop Question mark two lowercase or text Now let's define the status, which can be a type of ready, or processing, or error. And by default, let's set it to error. if status my apologies if entry.status is ready let's return my apologies let's set the status to be ready else if entry status is pending let's set the status to pending my apologies the processing now let's go ahead and let's get the url of the file url check if we have storage id await context storage get url storage id otherwise use null and finally let's return id entry.entryID name file name type extension size file size status URL category metadata question mark category or undefined. And if you've done this correctly you shouldn't have any errors in your code. Now we have developed the convert entry to public file and now all of our files here will have that specific public file format with id, name, type, size, status, url, and optional category which will help us in displaying it in that table. And now let's go ahead and filter them by category if the category ends up being provided. So filtered files will be arguments.category. If we have it, we're going to do files.filter. File, file.category matches the category from our arguments. Otherwise, just return the files. And now let's return page. Filtered files is done. Results is done. Continue cursor results continue cursor. There we go. Now we have the function to fetch our list of our files, which we have embeddings for, using the safe namespace, and by converting them to public files, as well as providing the size of each file, just so we can know more specifically what file we are talking about here. So as for this function here, the reason I didn't explain it too much is obviously because I didn't write this function myself. I basically found one of the packages that exist and with AI I just created my own little file here for a simple reason. It's super simple and I think adding a whole package just for this kind of makes no sense right. We can just do something like this and we'll see CodeRabbit review this if there's an obvious issue here. So now let's go ahead and let's use these files. So I'm going to go inside of apps, inside of web app dashboard and let's go inside of files, inside of page.tsx. Let's keep this open and let's go inside of modules and let's go ahead and create new files here, module. Let's create UI and let's create screens, my apologies, views and inside let's create files view.tsx. Mark this as use client. Export files view component. Files view. Now go back to page here and simply return files view. As simple as that. Now make sure that you have your app running, specifically that. Let's go to localhost 3000 here and let's check out if everything's working. So we should now be able to go inside of our sidebar, click on knowledge base and it should say files view. Perfect. Now let's go ahead and let's develop the files view. So what we're going to have to do here is we're going to have to import all the components from our table. So table, body, cell, head, header and row from workspace UI components table. While we are here, let's also import all the infinite scroll components because we know we're going to need them. So this includes the infinite hook and the infinite trigger. Then let's also prepare the paginated query from React. My apologies, from Convex React. This will help us fetch the files that we just created. And let's also prepare the API. let's see so workspace backend this cannot be found oh because it's like this yes my apologies and let's also import type public file from workspace backend private files so we have that type public file that we are returning instead of this files view let's start by adding const files and let's do use paginated query, API, private, files, list. And let's leave the argument empty. And initial number of items is going to be 10. Now inside of here, let's go ahead and let's do json.stringify and pass in the files. And as you can see, we have no files at the moment because we didn't even add any. so now let's go ahead and let's start developing this component so I'm going to give this div a class name of flex minimum height of screen flex column BG muted and padding of 8 BG muted there we go now inside of here let's go ahead and let's limit how wide this will go so let's add a new div Let's give it a class name MXAuto full width maximum width screen MD. Let's go ahead and open up div here with a class name space Y2. Let's add an H1 element knowledge base. Let's give it a class name text to Excel on medium text for Excel. Now, below it, let's add a paragraph, upload and manage documents for your AI assistant. Let's go ahead and give this a class name of text muted foreground. Now, let's go ahead and separate this. and let's give the div below it a class name of margin top 8 rounded large border and let's add bg background now inside of here let's add the table header well not exactly the table header but a space above the table. With flex items center justify end border bottom px6 pui4. And in here let's add button from workspace ui components button and let's add plus icon from lucid react and add new button and let's go ahead and add on click here for now to be just an empty arrow function make sure you have added the plus icon and there we go now you have a new add new button let's go outside of this div here and let's add our table element now let's define the actual table header inside of this table header let's add the table row now inside of this table row, let's add name with PX6, PY4 and font medium under the table head component. Now let's go ahead and do the same thing for type. Let's go ahead and do the same thing for size. And let's go ahead and do the same thing for actions. And now we have name, type, size and actions here. Outside of table header, let's add table body. Now inside of here, let's go ahead and first add our infinite scroll because we can already do that here. Just after files, let's go ahead and let's add use infinite scroll status files dot status load more files load more load size 10 same as the initial number of items. Top element ref handle load more can load more is loading more well is loading first page and is loading more like that. Now let's go inside of this table body here and let's do the following open curly brackets open parenthesis and write an arrow function inside and then execute that arrow function. Now inside of here we're going to check if we have is loading first page we're going to return table row table cell and loading files text inside. Let's give this table cell height of 24 text center and call span of 4 because we have 1, two, three, four. So this will take the entire table. Now let's do if files.results.length is equal to zero. In that case, we're going to do a similar thing. So we can copy this. So let's add return and instead of loading files this will be no files found. And now let's go ahead and let's actually iterate over our files. So return files.results.net, find the individual file, add table row let's go ahead and add a class name here hover bg muted 50 opacity and key file id then let add table cell here and let go ahead and give it a class name bx6py4 font medium inside let add a div and let go ahead and add a class name flex item center and gap three and inside let's render a file.name and just before that let's add file icon from lucid react so make sure you have added file icon and that you render file name now let's go ahead and copy this table cell and then let's go ahead and change this with badge component so make sure to import this from the proper place import badge from workspace UI components badge like this. Let's give this badge a class name of uppercase and a variant of outline. And inside of here, we are going to render file.type. Let's copy this table cell again. And now we're just going to render file.size, nothing else. And let's do a slight little change here by also adding text muted foreground. We can actually remove font medium everywhere, I believe, because font medium is the default font, I think. I mean, for our app, not for all apps. Great. And now let's add the last one, table cell. Let's just copy the class name here. Let's remove text muted foreground. and in here we will have the drop down menu so let's import everything we need from the drop down menu so right above the table I'm going to add menu content item and trigger from workspace UI components drop down menu let's scroll down here to our last title cell let's open the drop down menu. Let's add drop down menu trigger. Let's give it as child prop. Let's render the button inside. Let's render more horizontal icon from Lucid React. Let's go ahead and give the button class name size 8. Let me fix the typo here. Size 8 padding 0 size small variant ghost. And then let's close the sidebar, my apologies, drop down menu trigger. Let's open drop down menu content. Let's go ahead and give it a line of end. And inside, let's open drop down menu item. And let's add trash icon from Lucid React and delete. Let's give the trash icon a class name of size4mr of 2. and the drop-down menu item class name of text destructive and on click of an empty arrow function. Great. Outside of the table here, let's check the following. If there is no first page loading and if files results at length are larger than zero, let's go ahead and let's render a div with a class name border top and inside infinite scroll trigger let's go ahead and pass in all the prompts that we have extracted from our hook so can load more is loading more handle load more and top element ref so that's our infinite scroll trigger so by default no files are found, right? Because, well, we haven't added any files. So now what I want to do is I want to develop the upload dialog. So inside of files UI, let's go ahead and implement components. Inside of components, let's go ahead and create upload dialog.tsx. Let's mark this as use client. and in here let's import everything we need. So we're going to start by adding everything from the dialog, the content, description, footer, header, title and of course the dialog itself. Then let's also prepare the input like this. Let's also prepare the label and let's prepare the button and let's also prepare use action from convex react and use state from react. Now there is one more component we need to add here and that's the drop zone. Thankfully if you remember I told you that we're going to be using Kibo UI. It actually has built-in drop zone but again we have the same problem. The installation CLI doesn't work in monorepos for me. So don't worry thankfully source code is available right here but if you want to use the exact same version as me i have added it you can see the link on the screen here go inside of ui components and find drop zone copy it from here let's go ahead inside of our packages ui source components new file dropzone.tsx, paste everything inside. And now let's go ahead and fix some issues. So react dropzone. Let's go ahead and do pnpm f ui add react dropzone. And once we add this package, let's see if our errors will be resolved. So we just added react dropzone for the UI component. and as you can see, no errors visible. Great. Make sure you have this file and save it. Now let's go ahead and let's import everything from our new workspace UI components drop zone. That's going to be the drop zone itself, drop zone content and drop zone empty state. There we go. and let's also prepare our API from workspace backend generated API. Perfect. Now let's go ahead and let's create an interface upload dialog props with open boolean on open change which accepts the open boolean and returns a void and optional on file uploaded method and then let's go ahead and do export const upload dialog. Let's assign upload dialog props, open, on open change, on file uploaded. Now inside of here, let's first assign the add file method using use action. We can do that by adding API private files add file. Make sure it is use action because add file is an action. Then let's go ahead and add a state uploaded files and set uploaded files. Let's use state here and let's go ahead and do the following. We're going to give it a type of file and an array like this. Now let's add is uploading and set is uploading use state false by default. And now let's go ahead and just define the upload form inside of a state. This is kind of breaking convention from our usual way of defining forms. And if you want to you feel free to use a react hook form. I just feel like this one is a super simple example. So we can have it in state. Let's do handle file drop here. Let's do accepted files to be a type of file and an array. Inside of here, let's get the file here. So the first one, if we have the file, set uploaded files, open the array and add file inside as the only item. if not upload form file name. So if we haven't attached a file name, let's go ahead and set upload form, get the previous value or, well, the current value. And let's update the form by spreading the current value and updating the file name as to what the current uploaded file actual name is. You're going to see what this is used for in a second. Perfect. Now let's go ahead and let's actually return a dialog. let's give it on open change on open change this is our prop open open this is our prop as well then dialogue content and let's give dialogue content a class name sm maximum width large now let's render the dialogue header dialogue title upload document and now below that let's add a dialogue description and inside let's just add a brief description to describe what's happening. Upload the documents to your knowledge base for AI powered search and retrieval. Now outside of dialogue header here let's create a div and let's give it a class name space y2. Let's add a label but let's actually use the component label that we imported. Let's edit HTML4 category. And let's write category inside. Now let's add input, which is a self closing tag, class name, full width ID category on change. Let's go ahead and call set upload form. Let's get the previous values. So we save them. Spread the previous values here and change the category to be event target dot value. Now let's also assign the placeholder to be, for example, documentation, support, or product. Let's give this a type of text and value upload form dot category. Perfect. Now let's do the same thing here. So I'm going to copy this div and\npaste it. And I will change the HTML4 here to be file name. This will be file name. And let's go ahead and just add a little span here, which will say optional inside of parentheses. And let's give it a class name of text, muted, foreground, and text extra small. And let's just add a little forced space here between the two. Now change the ID to be file name here as well. And instead of category update file name here. And the placeholder here is going to be override default file name. So if we want to call the file something different than what it's called, and let's change the value to upload form dot file name. now outside of this div let's add the drop zone component the drop zone is a self-closing tag and let's go ahead and give the accept prop the application pdf let's just fix this so okay let me do it like this application pdf will accept an array of items which end with .pdf. If we want to upload CSV files, we are allowed to do so as well. And if we want to add text plain, we're allowed to do that as well. And here's just another thing. If you want to, if you want to add Microsoft Word files, this is the official MIME type for it. It's extremely long. So only if you want it. I didn't even try adding Word files. You can just google Microsoft Word MIME type React drop zone and then just add it. Again, you don't need this. I just thought it would be fun for you to see. Now, it's going to be disabled if we are uploading. Let's go ahead and set the max files here to be 1. And on drop here, for now, let's make it an empty arrow function. Source will be uploaded files. And then it's not actually going to be a self-closing tag because inside of it we are going to add drop zone empty state and drop zone content like this. And finally let's develop the last part here. So let me just see. I think I might have done something wrong here. Yes. So after dialog header, add a div here with a class name space Y4. And let that div encapsulate all inputs and the drop zone like this. And now let's just indent all of that. There we go. so now after this div which encapsulates the input and our drop zone let's go ahead and let's enter the dialog footer like that the dialog footer will have a button and the button will be cancel let's give it the variant of outline let's give it disabled of is uploading and let's give it on click here for now to be an empty arrow function. And now let's develop the other button which will be either upload or is uploading button. so it's going to be disabled if uploaded files.length is equal to zero or if we are currently uploading or if there is no upload form.category value and let's also pass in onclick here for now to be an empty arrow function. And let's check if is uploading. We're going to say uploading. Otherwise upload. There we go. So now let's go ahead and let's develop these empty arrow functions. Let's start with the handle upload method. So after handle file drop, I'm going to develop const handle upload, which will be an asynchronous method. First, we're going to do set is uploading and set it to true. After that, I'm going to open try and catch block here. Instead of catch, I'm going to log the error like this. And inside of finally, I'm going to set is uploading to false. now inside of here let's first get the blob using uploaded files and get the first in the array if there is no blob found let's go ahead and simply return the method then let's get the file name by using upload form dot file name or if nothing was written we're simply going to use the blob name and now let's go ahead and do await add file so we added add file in the beginning here instead of this add file let's go ahead and let's add the bytes we can do that by using await blob array buffer let's pass in the file name if we want to override it let's pass in the mime type to be blob.type or default to text plain. The category will be upload for.category. This will help specify the embeddings. And then let's call on file uploaded question mark and execute it. Now let's develop the handle cancel method. Const handle cancel. This one will be quite simple. We're going to call on open change and set it to false. We're going to set uploaded files to an empty array and we're going to set the upload form and reset the category and the file name to an empty string as it was in the beginning. And now after we submit let's make sure that we call handle cancel method to close the dialogue. Now let's use the handle cancel and all the other methods independently. So in the drop zone here we added an empty arrow function I believe. Let's find the drop zone. On drop let's go ahead and call handle file drop. It's the method we developed first I believe. This error that I'm having is I'm 100% sure because of my TypeScript server. so when this happens what I do is I use command shift p or control shift p and just restart typescript server and after you do that it goes away so we fixed the drop zone empty arrow function make sure on drop uses handle file drop the cancel button should use handle cancel and this button should use handle upload. Let me just confirm that I don't have any empty arrow functions here. I don't. Great. Our upload dialog is now ready to be previewed. Let's go back instead of the files view component and let's modify our return to use empty fragments to wrap the entire app around. Let's indent the entire app and just above let's render the upload dialog like this. Now let's go ahead and let's define the states here. So I'm going to add const upload dialog open and set upload dialogue open. Use state false by default. Now let's go ahead and let's pass it some props. On open change we'll call set upload dialogue open and open we'll call upload dialog open. Make sure to import use state from react. One thing that we are missing here is on file uploaded or maybe we actually don't need to do anything here. Yes, I think this is fine. So now when we click on add new here let's go ahead and do set upload dialog open and set it to true. Let's go inside of our files now. Let me just refresh this. There we go. So now when you click add new, you should have this model open. You should be able to add the category, the file name and upload some files. Now I have actually added some files for you to use inside of my assets folder. So go back to this folder and you will find the knowledge base. And inside of here, go ahead and choose one. All of these are AI generated. So for example, you can choose something like this, frequently asked questions, just something that you can easily test. For example, some of your plans here, basically something that AI can easily learn how to answer. So just download one of these files here. I recommend using the frequently asked questions one and then go inside of here and let's try and add this. So I'm gonna go ahead and add this here and I'm going to call this frequently asked questions. I will not override the name and I will click upload and let's see if this will work or not. And just like that, you can see this works perfectly for me. So let me check my backend here actually. So as you can see, my backend had no problems handling this at all. And let's double check that everything is actually okay by going inside of my convex dashboard here, inside of Echo Tutorial, and I'm gonna go and first check my files. And there we go, you can see that I have my files right here. Perfect. But what's more important for me is whether the embeddings were created. So I'm going to switch to the rack component here and you can see immediately, this was completely empty before, but now you can see that it has the proper namespace ID and it has the embeddings here ready So I not exactly sure There we go you can see you can find the exact parts of the frequently asked questions embedded in here amazing amazing job so i recommend just going with one file for now simply because it's easier to work with and later you can test with more files now let's go ahead and let's implement the simple delete method i mean the delete dialogue so what I'm going to do is I'm going to go back inside of my components and I will create the delete file dialog.tsx let's mark this as use client and now let's import thing by thing so use mutation from convex react use state then let's add the button then of course let's add all the components needed to develop the dialog that's dialog content, description, footer, header, and title. Then let's go ahead and let's import the API from workspace backend underscore generated API. And let's do the same thing, but for importing the public file from workspace backend private files. Now let's go ahead and let's create the interface delete file dialog props, which are going to be quite similar as to our upload file dialog and it's also going to have file which is a type of public file or null as well as on deleted which is an optional callback. Now let's go ahead and actually open the export const delete file dialog and let's go ahead and assign the props here which means that now we can go ahead and destructure all of them open on open change file and on deleted. The first thing I'm going to do is I'm going to add the delete file mutation using API files my apologies private files delete file. Let's also create is deleting set is deleting use mutation my apologies use state false by default. Then let's develop handle delete method, which is going to be an asynchronous method. If there is no file attached, let's simply return. Otherwise, set is deleting to true. And then open a try, catch, and finally. In the finally, set is deleting back to false. In the catch, let's catch the error, and let's log it. In the try, let's do await delete file. Entry ID file dot ID. Let's call the on deleted optional callback. And on open change will be false. As simple as that. Now let's go ahead and let's develop the dialog composition. So we're going to render the dialog. On open change will be on open change prop. open prop will be open prop dialogue content will have a class name of small maximum width medium dialogue header dialogue title delete file let's do dialogue description and inside something descriptive as to what we are doing are you sure you want to delete this file this action cannot be undone. Outside of dialog header let's conditionally render if we have a file a little div here that will help us display what we are deleting. So use py4 here so it looks better and has more space. Add a new div inside. Give it a class name rounded large border background muted with 50% opacity and padding for. Inside add a paragraph containing the file name that we are about to delete and give it font medium. Below that go ahead and add the type file dot type to uppercase. And then let's go ahead and add a pipe size and then render file dot size. Like this. And then finally, oops, let's also give this paragraph a class name here. TextMutedForeground and TextSmall. Outside of this file conditional, let's render the dialog footer. Let's render the button inside. The first one will be to cancel. It's going to be disabled if is deleting. OnClick, it's simply going to call onOpenChange and setFalse. The variant will be outline. Let's go ahead and copy this button. And let's call this one delete. In fact, we can just do the same thing. IsDeleting. Deleting. Otherwise, delete. And let's give it a variant of destructive. let's disable it if we are deleting or if there is no file selected and let's call handle delete on click perfect that's it now let's go back inside of the files view and inside of the files view let's render the delete file dialog Let's go ahead and duplicate this and change the upload dialog open to be delete dialog open. And then we can duplicate these and we can add them here and we can change them respectfully. There we go. But what's missing here is the file. So how do we select the file? Well, let's go ahead and develop one more thing here. Const selected file, set selected file, use state, and by default is going to be null. Let's go ahead and change this to be either public file or null. Just ensure that you have public file type imported. And then let's develop const handle delete click here. and the prop will be file, public file. Set selected file, add the file and set upload dialog, my apologies, set delete dialog open will be set to true. And let's do const handle file deleted. It's very simply going to be set selected file null. and now we can add both of them to the delete dialog here so that will be the file selected file and on deleted handle file deleted and I think I'm yeah I'm not using handle delete click yet so we're going to use handle delete click in the table let's go down here and let's find our actions. So our drop down menu. Inside of here we have the delete with an empty arrow function. Let's change that and let's pass in handle delete click and file inside. So which file? Well this one. The one we are iterating over right here and it's a type of public file. So all of these should match properly with the types. So let's double check. I now have a fully uploaded and embedded file. So I have some content. I seem to be having some chunks, entries, namespaces. These things inside of my Rack components are full. And if I go inside of my files here, specifically you have to change this to app, you have this file. Now let's go ahead and let's delete it. Now let me confirm. This seems to be working because the list was immediately refreshed. in here inside of my app file storage no files have been found now let's check my rag here and you can see my chunks are empty my content is empty my entries except my namespaces right so only the namespace has left simply because the namespace can happen again organization right but you can see that the entries content and the chunks are all gone our delete method works well Amazing, amazing job. In the next chapter, we are finally ready to implement the search tool, which will be able to use these embeddings and generate answers to the user. So just ensure that you are able to add files. They should be able to appear inside of your app file storage. So always make sure you're looking at the app here when you are in the files. And when you go inside your data, RAG, you should be able to see some chunks, some content, and some entries. And when you delete file, all of that should go away. Amazing, amazing job. So we double check the dimension number. We created file as a list function. And we created amazing UI. Let's go ahead and merge this. 21 knowledge base. So I'm going to close this. Let's stage all of these changes. 21 knowledge base. Let's commit. And let's go ahead and open a new branch. 21 knowledge base. And let's publish this branch. And as always, let's go ahead and let's review this pull request to confirm that we don't have any serious security issues here. And here we have the summary by CodeRabbit. We introduced a comprehensive file management interface with paginated browsing, infinite scroll, and actions for uploading and deleting files. We added dialogues for uploading new files and confirming file deletions, supporting file metadata and user feedback during operations. We implemented a drag-and-drop file upload component with support for file type and size restrictions, as well as visual feedback and upload states. We improved error handling and user feedback during file upload and deletion process We updated dependencies to include support for drag and drop file uploads That exactly what this chapter was about And we have just a few comments here. So again, I completely forgot. I promise, next chapter, we're adding toaster. I forgot again. Absolutely. Thank you, CodeRabbit. So in here, privatefiles.ds. it recommends trying to filter the category at the retrieval level right so in here we are using the actual dot filter so code rabbit is concerned because that's not obviously the most optimized way so it's look so it wants me to look into if it's possible to do this instead of rag dot list i will look into this just at the top of my head i'm not sure if we can do that but yes if we can do that it would be the preferred way again very very good comment now in here convert entry to public file so it suggests having early mechanisms to detect if entry metadata is incorrect so if a storage id is missing or file name is missing we can't exactly convert that to the public file So obviously that would be considered invalid metadata. I will look into this. I'm okay with the metadata we've made actually. So yeah, I'm trying to think if this gives us any benefit since it is a backend function. Having any console warn will be logged. So that will also be sent to sentry. So it could be useful, but I'm not too sure because I think we have other mechanisms which guarantee these. But yeah, maybe it wouldn't hurt to just add some more console logs. We'll see. And either way, very good pull request. Not any security issues. That's great. So we merge this pull request. Now let's go back instead of main. Let's synchronize the changes. And as always, when we synchronize the changes, let's go ahead and review our graph. 21 knowledge base merged perfect and just one more thing i want to left you with before i go so when it comes to uploading files instead of convex you have a couple of options so what we're doing right now is we are uploading it with a simple mutation now the trick with this is that i don't think there's a file size limit but i'm pretty sure there is a timeout limit so if you try to upload a huge file, it will probably keep you in this weird upload state. I haven't managed to get an error. I uploaded a pretty large file when I tested this. It just took a long time and it kind of hanged there, but eventually embeddings were created and it all worked great. So just a quick tip for you, if you're trying to upload some large files, please, you know, I'd rather you test it with the files I provide you with. Like these are all very, very small text files and they're super easy to test. And then later, try PDFs and try images and things like that. Just a small tip for that. I would recommend taking a look at the upload here so you can find all the different methods of how you can do that. Great. Amazing, amazing job. And see you in the next chapter. In this chapter, we're going to implement the AI search tool, allowing the AI to use the previously created knowledge base and embeddings and provide our users with answers. Let's go ahead and develop the search tool. Inside of packages, backend, convex, system, AI, tools, create a new file called search. Now let's go ahead and import our AI model. In my case, that's going to be OpenAI from AI SDK OpenAI. In your case, it is whatever you've been using so far. Then let's go ahead and let's import create tool from convex dev agent. Now let's go ahead and import generate text from AI package. Let's import Z from Zod. Let's import internal from generated API. Let's import support agent from agents support agent. Let's import rag from rag. Let's export const search to be a tool. So create tool and let's give it a description. So in the description, you would provide something relevant for the AI to know when to use this tool. Search the knowledge base for relevant information to help answer user questions. As for the arguments, let's go ahead and use Zod to define this object. So we're going to accept a query, which is a type of string. And let's add the scribe here, the search query to find relevant information. So the way this tool will be used is the AI will detect the user query, and then it's going to pass it here as the argument, as in the user query. And then we're going to use our embeddings and the rag component to search for that. So let's go ahead and mark this as async. Let's add arguments here. And now let's first check if there is no context.threadID. Let's return missing threadID. We can't continue further. Now let's go ahead and let's find the conversation. const conversation and let's go ahead and reuse our internal conversation query. If you remember in the system we have conversations right in the system folder and in here we have get by thread id so let's reuse it here await context run query and let's go ahead and use internal system conversations get by thread ID and pass in thread ID context thread ID. So the reason we have to use the internal query and the reason we need to use context run query is very simply because context.database is not available here. The reason it's not available is because createTool is an abstraction over action. So the context property does not have the database here. We are basically calling the third party tool here, OpenAI. So we don't have direct access to the database in here. Now let's check if we don't have the conversation. And let's simply return conversation not found. So we're not throwing errors here because this is a tool. Instead, we're just returning messages like this. Then let's grab the organization ID here, conversation.organizationID. And I think this should always exist, right? Yeah, so no need to check anything here. And then let's do a const search result. And let's do await rag.search. Let's give it a namespace organizationID. Let's pass in the query and let's limit this to five. The query will be arguments.query. and that's how you use the rag component to search through the embeddings based on the query and the namespace and now let's go ahead and let's standardize what we found so context text will be found results in search results my apologies search result dot entries dot map for each entry return entry.title or null filter for each title, check if it's not null. Did I do this correctly? Oops. T isn't null. And then join by adding a comma and space like this. And add here is the context go ahead and add backwards slash n backwards slash n so we have new line search result dot text so this isn't exactly for the human to read this is just formatting the search result from the rag component in a way that will be easier to pass further now to ai so let's now generate the response so the AI can respond okay here's what I found or basically right now we can only return things like this but this isn't super readable imagine if you ask what is your most popular plan and then we just returned with found results in here and just give you a million results because the embeddings kind of match we don't want that we just want to kind of summarize what we found And then we want to use AI again using generate text, passing the messages in here. The first one, the role system and the content. Now inside of the content here, let's go ahead and simply say, you interpret knowledge, base search results and provide helpful, accurate answers to user questions. Then let's add another message here. This one coming from the user. And the content will be user asked arguments.query. So that's what the user asked. And then let's add backwards slash n, backwards slash n, search results, content text. Like this. And let's add the model and basically use your model here, openai.chat or gemini.chat. And then you can just use whatever you've been using so far. I will use 4.0 mini. And let's see, this is not contact text. This is context text. Like this. And once this is generated, let's do await. support agent save message context thread ID context thread ID message role assistant content response text like that and let return response text perfect so what's going on with this tool so basically the first thing we do is this tool will be called with the user query so the user will ask what is your most popular pricing plan and then we're going to store that in this query here and once we find the matching conversation by the thread id we're going to pass that query into our rag search and that will return the embeddings or the entries from our knowledge base which match that question and it will return a text like this and we are kind of going to standardize it so it looks just a little bit better but this is not exactly a readable format. This is huge information right now. So we then pass that into your AI system, which goal is to interpret knowledge-based search results, which is this. These are the search results and provide helpful and accurate answers to user questions. And then we simply repeat. So the user asked this and this, and these are the search results using whatever model you've been using. And then we simply store that response.text from the AI above inside of the message. So now let's go ahead inside of our agents, support agent. My apologies, this will be inside of public messages. And in here, in the create action, where we already have escalate conversation, let's do search. from system AI tools search. And now if we've done everything correctly, we should have a working example. So how do we exactly test this? Let's go ahead and first load our dashboard. Let's go inside of the knowledge base here. And let's also prepare our widget with a working organization ID. I would highly recommend that you use the exact organization ID that you're logged in with, right? So you can use the clerk dashboard to obtain organization IDs. So you can test that very easily by just adding a new question here. Let me start a new chat. Test, test, test. Okay, so I am in the correct organization. My organization is test22. So dashboardclerk.com, this is how you will do it. You will go inside of organizations, find test22, copy the organization ID and in your widget make sure you're using that organization ID in the URL. Perfect. So let's go ahead now and let's try adding something to our knowledge base. So if you remember in the previous chapter I believe I gave you knowledge base. So I would suggest choosing something super simple that you can test. Let's see we have pricing plans here so that would be interesting to look at but we can also do like frequently asked questions because I think this is the simplest possible thing. So let me download knowledge base frequently asked questions here. If you already have it, great. So I'm just going to go ahead and add this again, frequently asked questions. I will upload this and there we go. I have it right here. So if we've done this correctly, we should now be able to ask it something about frequently asked questions. Let's just open this file again just so we know what's inside. For example, if I ask how do I reset my password, I should get steps like this. Or if I ask it what are the pricing plans, it should return me with these pricing plans. Let's try something. Let's go inside of the widget. let me refresh and let me ask what are your pricing plans and let's see if it will use the tool or not given that it's taking a while to answer i'm assuming it's using the tool and there we go we offer three subscription tiers for our pricing plans the starter plan professional plan and the enterprise plan and it uses the sales example.com let's see if this is made up or actual information so $29.99 a month let's see if that was correct or not $29.99 a month I think that is correct right and starter is $9.99 a month exactly perfect and saleasexample.com let's ask it something else do they offer refunds so we should expect a 30-day money-back guarantee let's go ahead and ask that do you offer refunds let's see we should get back a return about a 30-day money back guarantee amazing amazing job so this is how the embedding function works now we have the search tool to actually test it but still in my opinion this wasn't fully perfect because I could still ask at things like can I get married like something completely random and you can see it just tells you yes you can get married if you have specific questions about the marriage process feel free to ask right so something weird it will still answer you the same way a normal chatbot would answer you so the way I fixed this was simply by making prompts a bit stricter so let's go ahead and do that now? I have added inside of my echo assets folder constants.ts in which I have all of the prompts that I have used. So let's go ahead and add them and let's improve our app now. So inside of packages, backend, convex, system AI in here, add a new file constants.ts and paste everything inside. So you should have support agent prompt, search interpreter prompt, operator message enhancement prompt. So three of them. So let's start by modifying the support agent prompt. Go inside of your support agent and change the instructions to be support agent prompt from the constants. So let's take a look at it here. So we have some normal things here. You're a friendly, knowledgeable AI support assistant, but be careful here. So make sure that you named these tools the same way I named them, right? So if you are calling these tools something else, then replace them. So what am I talking about? I'm talking about messages.ts in the public folder. When you create a new message here, we're passing some actions. So escalate conversation, resolve conversation, and search. So yeah, perhaps I should call this then search. And I should call this escalate conversation. And I should call this resolve conversation. Even though I'm pretty sure AI can understand what I mean, or maybe simpler, leave the prompt as it is. And then simply call this tool escalate conversation. Call this tool again, resolve conversation. call this tool and just add search that's even simpler or you can rename this to tool if you want to and then all of this will make sense and in here I give it some examples like blah blah blah but basically what I'm telling it here is that it shouldn't make up information and it shouldn't serve as anything other than a customer support agent tailored to its knowledge base. So critical rules never provide generic advice, only info from the search results. Always search first. And if you are unsure, offer human support. Basically, don't guess, right? Because why should your AI be able to answer a question like, can I get married? Right? That's kind of weird. because you're not building a general purpose customer support. You're building a very tailored customer support. So that's why I added this prompt to kind of improve it here. And let's see where else should we use it. So we just added the support agent prompt. Now let's do the search interpreter prompt here. So this will actually be used inside of the search.ts inside of the tools folder, which we just developed here. And in here, I basically use this super simple prompt, which is good enough, But let's actually add search interpreter prompt here. Just so in here, it also doesn't hallucinate. Basically, if it found information, return the information. But don't return, for example, some generic information. In here, you can see I give it examples. Good response is specific information. Good response is also partial information and then offered to connect to a human to provide more details. bad response is making things up. So when it cannot find an information and then it responds, typically you would go to settings and look for a password option. That's wrong, right? Why should the AI tell you that? AI shouldn't guess about your product. It either knows the answer from the embeddings or it has no idea what it's talking about. And in here I also have operator message enhancement prompt and I use this inside of messages in the private folder. even though I'm not too sure it's needed. But yeah, it's basically for the enhance response action when the operator clicks to enhance their message. Even though I think this is more than enough, if you want to, you can use the operator message enhancement prompt because that's what I used when I developed this. But maybe it's a little bit of an overkill. But yeah, I'm using Markdown because it works well with OpenAI. I think it works well with all other Gemini models and Anthropic models, but not. So let's just for fun try now as well. So I'm going to go ahead and leave this chat here. I will start a new one and I'm going to go ahead and ask it, can I get married? And let's see if we improve this or not. And there we go. Exactly what I wanted it to say. I don't have specific information about that in our knowledge base. Exactly what I wanted to do. So what is the price of pepperoni pizza? Things like that. So if we ask it something stupid, it should always tell us that it can't find the information about that. Would you like me to connect with the human support agent? Yes, please. And you will see conversation as close.\nto a human operator and that is also reflected in here there we go it is escalated and then we can chime in and say hey we really don't have pizzas and if you want you can enhance that here and send it back so yes now it will understand yes they they actually don't offer pizzas and you can't get married using their app but if you ask something specific like what is your most expensive pricing plan it should be able to find that because it has the frequently asked questions embeddings so let's see will it answer us that or not so i suggest that you test this by adding simple text file oh it can't answer this because we switched to escalated mode my apologies so let me start a new chat here so what is your most expensive pricing plan uh let's see are we maybe too strict with our looks like we are not too strict so the most expensive pricing plan is the enterprise plan which has custom pricing this is correct so it correctly extracted from our pricing plan so if yours for whatever reason is very stubborn and keep saying i don't have the information i don't have the information it could be that the prompt is a little bit too strict so you might have to dumb it down a bit i basically yeah my my goal was here this if it's not in the search results you don't know it offer human help instead but sometimes that can be a bit too strict and then ai is afraid to say anything other than recommend a human which is technically a good thing but it kind of defeats the purpose of having an ai helper uh so i recommend that you test with like simple ai tools and then maybe try some more interesting ones like adding a pdf file or something but try to keep them small in size it's going to be much easier for you to test things excellent amazing amazing job so what if this is not working for you how can you debug and see what's going on well in here what i wanted to show you is the convex developer playground so if you go inside of agents and click on the playground here they actually developed a ui where you can talk with the agents without having to build anything yourself so let's add this to our backend here pnpmf backend add convex dev agent playground basically this right here then let's build playground.ts file in the convex folder. Packages backend convex playground.ts. So on the same level as out and convex config. And in here I think we can copy all of these things. Let's paste them. Let me just see. It cannot find convex dev agent playground. Did I add it correctly in my backend. I did. It should be available here. Let me just see. Did I do something incorrectly maybe? No, it should be here. Maybe I just have to restart TypeScript server or refresh. Yeah, I just had to restart the server. And now we have to actually use the system here. AI agents, support agent. That's the only agent we have, support agent. And then simply add support agent. Just like that. There we go. Once you have no errors in your playground here, let's go ahead and continue further. So in your project repo, issue yourself an API key. So let's go ahead and just copy this part because we're going to be using PNPM for this. So let's close this. And let's have a new terminal open here. But Keep this running. I'm pretty sure you have to have this running. Make sure your backend is convex functions ready. And then go inside of your packages. Go inside of backend here. And go ahead and run pnpm dlx, convex run, component agent, API keys, issues. And in here, I think you can just put test here. And now you have this API key. so just copy this API key here and then run convex dev agent playground so pnpmdlx what was it again convex dev agent playground inside of the back end folder here actually I'm not sure if you even need to be inside of the back end folder for this and now in here go ahead and paste that API key you just copied and click submit here and in here you can select the existing user that you have and you will see all of their conversations and this makes it easier for you to test things right because you can see that it uses the search tool you can see the query that it was passed most expensive pricing plan and you can see the return value which happened. So you can see exactly, for example, if I go up here, what is the price of pepperoni pizza? It used the search tool and the query was price of pepperoni pizza and we couldn't find any information about that, right? So that's why this playground tool is quite useful because it's easier to debug. If you are convinced that something should work but it doesn't, open it in a playground and then try and see why it doesn't work. And you also have the entire system prompt in here so you can change it in here directly to see what works better. So if yours is very stubborn, try maybe changing the prompt from here and then try to get it to answer something. Perfect. So I would recommend that you develop, that you research the playground even more because it's super cool and it can definitely help you in assisting you to improve your prompts and AI agents. And I think that marks the end of this chapter. So let's go ahead and see. We developed the search tool and we improved our prompts. Our AI is now able to tell us exactly what we found in the knowledge base. Let's go ahead and just shut down this like that. And let me just test one more thing. So right now it's able to tell us everything about our pricing plans right but what if I go in here and what if I delete this so now if I go ahead and try again and ask what is your most popular pricing plan let's see will it still be able to give us that and as you can see now it has no idea what is our pricing plan because we just remove that embedding and we remove that knowledge base. So all of our previous work was 100% correct. Amazing, amazing job. I think this is super impressive what you've just developed. So 22 AI search tool. Let's go ahead and merge all of that. 22 AI search tool. Let's commit. Let me change to a new branch. 22 AI search tool. And let me publish this branch. And let's go ahead and let's review using CodeRabbit. I'm pretty sure there are no security issues here whatsoever, but maybe we will get some interesting comment about our prompt or something we can improve. And here we have the summary. We introduced the Playground API for front-end access to AI agent features, including agent management, thread creation, messaging, and prompt context retrieval. So this is the last thing we did, a simple playground for you to test your AI agents. We also added a new knowledge base search tool, enabling AI driven search and contextual responses within conversations. Exactly what we did. In here, we have a diagram explaining how all of this works. So let's see, the user asks a question, the support agent receives the question, and it invokes the search tool with query because of the prompt that we used. And now inside of that tool, what we do is we first fetch the conversation by thread ID provided from the context. We then return back the conversation or error. If the conversation exists, we have the namespace organization ID. Using the namespace, we can search the RAG component and we can return the five results from that context. and then we simply interpret the answer using another AI model and we save that message in a thread and finally respond with that answer. Amazing. In here the only comments are to throw errors instead of returning strings which I'm pretty sure we cannot do inside of tools. I think we should return strings as we are doing. So yes I think this is perfectly fine the way we did it. Great. Amazing, amazing job. Let's go ahead and just go back inside of our main branch. Click on synchronize changes right here. OK. And once the changes have been synchronized, always check the graph to confirm that you've just merged 22 back inside of main. And I believe that marks the end of this chapter. Amazing, amazing job. And see you in the next one. in this chapter we're not going to write any code instead we're going to learn how to use aws secrets manager product so if you remember in the chapter six when we developed the vapi voice assistant i told you that the plan is to white label vapi's api and allow our organizations to bring their own keys. Now, I think we all kind of understood why we need that. It is because if you have a single VAPI API key, then you have only phone numbers which are scoped to that API key. Same with assistants and same with tools. And same with knowledge base. So the entire knowledge base, which now you know what it is, is scoped under VAPI API, a single API key. So in order to allow each of our organizations to have their own phone numbers, their own assistants, we need to white label VAPI's API. And in order to do that, we need to allow them to add their own API keys. And then this will allow each of our organizations to have encapsulated knowledge bases tools assistants and phone numbers And while we understand the concept behind that there is this very important method of how are we going to store their API keys So I have decided to use the AWS Secrets Manager. We're going to start by creating the AWS account, and then we're just going to follow the steps I have outlined here to make sure Secrets Manager is ready to use within our app. So let's start by creating AWS account. Head to AWS website, you can see the link on the screen, and create an account or sign in if you already have an existing account. If you are signing in for the first time or it's been a long time since you've signed in inside of AWS, you need to remember that there are two ways you can sign in. You can sign in using the identity user or you can select the root user. So what is the difference between the two and why is AWS seemingly strict on preferring identity users instead? Well, what's important for you to know is that in a real world example where you would work for a company which uses AWS, you will never get root user access. Instead, you will have your own identity user which has basically limited permissions. The reason behind this is if a company uses AWS, they are more likely to use hundreds, if not thousands of their services. And imagine the catastrophe that can happen if someone who is with evil intentions gets access to the root user and crashes all of those services, right? So that would be very bad. That's why whenever they give access to employees, they use iim user access but if you're developing this for the first time you've just created aws account you don't have this right you only have the root user or if you're like me and you're just solo developing this for yourself you're not using your company's account then you know your email and you know your password so feel free to select root user and use the email and password you just created. Once you are inside of this dashboard, your might look a little bit different than mine simply because I already used this to create the secrets manager. So you can see that I have some recently visited items. You probably don't have those. What I want you to learn from here is how to find out your region and how to check if your account has any credits inside. So when you look in the upper right corner here, under your account, you will find if you have any credits remaining right here and how many days you have remaining. But you can see that even if these days expire, you still have free plan if you didn't spend the credits. So I have no idea how I got this. I think this is the default for every new account. And I'm pretty sure AWS has like a ton of programs if you're a student or something you can get free credits and for your region you can see it right here so click here and just kind of try and be aware of what your region is so for me it's this us east one i'm not sure if that's the default it wasn't pre-selected for me i have no idea but i developed the app on this region so just be aware this is the region you are using Now let's go ahead and let's see what is the next step. So we just did step one, creating the AWS account. Now let's create that user within our root account that will only have permissions for certain things. So let's go ahead and let's search for IAM, services, manage access to AWS resources. And now inside of here, go ahead and select users. and you can see I already have one user you probably don't have this because well if it's a new AWS account you've probably never created this. So let's go ahead and learn how to create this user that we're going to need which will have access to secrets manager. So let's click create user right here and let's go ahead and give it a name. So I'm going to call this echo secrets and I'm going to give it a suffix of bot because this will only be used as programmatic access. So I won't check this. I will leave this empty because this isn't a user. This isn't a person. We're only creating this so that we can later generate access keys for this specific user and then use them inside of our project. This way even if those keys leak they won't have access to our entire AWS. And as far as I know, this is the only way you can connect to AWS. You need to create a smaller subset of permissions like we are doing right now. So Echo is the name of our app, and then Secrets as what these permissions will be about, and Bot to indicate this is not a person, this is programmatic access. And let's click Next. Now, for the permission options, go ahead and select attach policies directly and in here we don't really have the policy that we need so let's click create policy this will open a whole new tab here and now we have to create a new policy so I like to use the visual option if you are super you know capable of doing this JSON option you know what you're doing sure you can use it but I just like visual I think it's easier to work this way. So let's choose a service. This will be Secrets Manager. So let's select that. And now we have to add the values. So the effect is allow. Let's go ahead and go inside of read. So the first thing we're going to do is we're going to allow get secret value. And then in the right, we're going to allow create secret. So already we can create new secrets and we can obtain them. Let's also do put secret value and let's also do update secret like that. Delete secret itself won't be needed really because even if user disconnects their VAPI integration, we're not going to delete their secret. We're just later going to update it. And now inside of here, we need to create a specific resource. So let's go ahead and click add ARNs and inside of here you have to add your region. So what is the region? Well this one US East 1 right that's the region I'm going to be working with. So US East 1 like this. If you really want to you can also select any region. So you can maybe try this later if for some reason the reason the region you have entered is not working and now for the resource secret here we're going to go ahead and do the following tenant forward slash and then an asterisk so basically we're going to only allow this under this specific resource secret so tenant is basically organization so we are creating a wild card access here so that the secrets can only be stored under this specific format and let's go ahead and click add ARNs like this and I think that that should be all let's go ahead and click next so this policy name will be called, let's call it echo tenant secrets manager access. So echo tenant secrets manager access like that. Perfect. Now that we have created this, let's go ahead and click create policy. And now this policy will take some time to be created here. So I think even if you try and search for it. So Echo Tenant Secret Manager, it might not even be available immediately here for you. But that's okay. That's what happened for me the first time. It took some time to be created. So you can either refresh policies here and see if it appears then. If not, wait maybe five to ten minutes. Let's go ahead and click view policy. If you have it available, fine if you don't. But let's just review if everything here is correct so the permissions are only for secrets manager and we can only read and write and for the let's let me try and find the ARNs okay it's right here ARNs are right here okay and we are only doing it under US East 1 resource here now what we have to do we have created this policy and now we have to go back remember because we were developing the user this should be in your previous tab so you can even start this whole user process again it doesn't matter right so just go ahead and specify user details echo secrets bot don't check this click next here and then in here you have to find that new echo policy the problem is here as you can see It's not really appearing for me, but let's see, I just hit refresh and there we go. Now I have it. So now I can select it. Even if you still can't do it, I would recommend maybe waiting five to 10 minutes and try refresh again. But even if it's not appearing, then you can continue without adding permissions. You can add them later, right? And now let's go ahead and click next here. And I think that everything here is fine. So user details, echo secrets bot, permission summary, echo tenant, secrets manager access. Let's go ahead and click create user here. And there we go. I just have my new user, echo secrets bot. Perfect. Now that we have this, we have to create API keys that will be used to access this echo secrets bot. So let's go ahead and click create access key. and in here what we have to select is application running outside AWS. Now in here you're going to get the warning to use IAM roles anywhere to generate temporary security credentials. I did not have to do that and I think it might be a little bit of an overkill to do so and I not 100 sure but I heard that AWS likes to push these roles simply because then they have their own courses on that and templates and certificates and whatnot But it more than enough for you to just select application running outside of AWS and click next here. And for the description tag value, let's go ahead and simply say echo project, I guess. Or maybe we can specifically say echo convex backend. I'm not sure it really matters, but let's just add something in here. And now in here, you're going to have your access key and your secret access key. Let's copy both of them. And I know I said we're not going to write any code. Well, technically, this isn't code. We're just adding to our backend environment.local here. So let's go ahead and let's add. Let me see. So this is AWS access key ID. And the other one will be AWS secret access key. Let's go ahead and copy the secret access key. And let's paste it here. And let me just double check that this is how you name these. So AWS access key ID, AWS secret access key, and also AWS region will be US East 1. so the same one that we have defined multiple times you saw that when I logged in it was US East 1 here and inside of the echo secrets bot I think that you will see that we have only set this permission maybe this policy to be here it is US East 1 so make sure that you are using the same region here so make sure that you have added these three inside of your packages backend environment local and I'll copy them and head inside of your convex here your project settings environment variables add and paste all three and click save all just like that and I believe that's everything we had to do we created the I am user I am policy we created the ARN for tenant wildcard we attached the policy to the user and we created the access keys for the user and then we stored both the keys and the region in environment local and in the convex cloud. So now we are ready for the next chapter when we are actually going to install the AWS secrets manager package in our project and use it to store some API keys. Amazing, amazing job. Of course, if something goes wrong, if it ends up being the case that something's not working, we can just try again. But I'm 99% ensure we did everything correctly here. I believe that marks the end of this chapter. There is nothing, no code to review here because we didn't write any. So yeah, I think that's it and see you in the next chapter. In this chapter, we're going to implement a WAPI plugin, allowing the users to add their API keys and we are going to use the AWS secrets from the previous chapter we've set up. Let's start by adding AWS secrets library to our project. So go ahead and run pnpm f backend add aws SDK client secrets manager. Once this has installed, double check that you have it inside of your package JSON inside of packages backend. As you can see, I'm using version 3.859.0. You don't have to use the exact same version, but if you see a change in the major version, such as 4 or 5, there might be some differences in the API. But I suggest that you follow along or install the same version as me. And then if some problems occur, you're going to have to look at the documentation. Let's start by going inside of Packages, Backend, Convex, and let's go inside of Lib. Inside of here, createSecrets.ts. Let's go ahead and let's import everything we need from AWS SDK Client Secrets Manager. We're going to start by importing the createSecret command. After that, let's add getSecretValue command. Then let's import the type getSecretValue command output. And then let's add a few more commands. Put secret value command, resource exists exception, and secrets manager client. Now let's export function create secrets manager client. Its return type will be secrets manager client. Inside of here, let's return new secrets manager client. inside of region, go ahead and pass process.environment.aws region. Inside of credentials, open an object here and add access key ID to be process.environment.aws access key ID for an empty string. And same thing for secret access key. let's go ahead inside of our environment local in the back end let's just double check from here so I like to copy environment names because it's always safer this way you can sometimes misspell things and not notice them just like that same thing with AWS region what's super important is that you have also added this to your convex account so just double check inside of settings environment variables that you have all of them and that they are named exactly the same. Great. Now we have the create secrets manager client here. Now let's go ahead and let's implement a function to get secret value. Expert function get secret value. It will accept secret name as the prop, which will be a type of string. and it will return a promise which resolves to getSecretValueCommandOutput. Let's go ahead and define the client in here using createSecretsManagerClient. And let's simply return awaitClient.send new getSecretValueCommand and inside secretId will be secretName. let's go ahead and make sure that this is an asynchronous function there we go and now we shouldn't have any errors now let's go ahead and export another asynchronous function called absurd secret this will accept secret name which is a type of string and secret value the value will be a record string and unknown So yes, we're going to store objects here and we're going to stringify them so they are easily accessible inside of the AWS dashboard and so that we have a more advanced type that we can work with because storing them individually is kind of a problem because it's easier for us to simply ask the user for all of their API keys from a single service like Vapi and then stringify that and save that rather than creating individual secrets. That's why we're going to be working with objects. And the return here will be a promise and void. Let's first define the client here to be createSecretsManagerClient. And let's go ahead and open a try and catch. Instead of try here, let's await client.send. and we are now going to try new create secret command. Name will be secret name and secret string will be json stringify secret value. That's how we are going to store new secrets. But in case an error happens, it can happen for multiple reasons. But if the error is instance of resource exists exception, that means that the user already has this secret. In that case, what we are going to do is we are simply going to update it. So client.send new put secret value command secret ID secret name secret string again JSON stringify secret value. As simple as that. And in the else here we're simply going to throw the error because it's not something that we can handle. Great. We now have a function that allows us to either create the new secret or if we notice that we already have that value, we are simply going to update it with new API keys. And now let's create a simple helper to help us parse the value because we store it as a JSON string. So let's export function parse secret string. let's go ahead and set t to be type of record string unknown the prop here will be get secret value command output and it will return t or null now let's go ahead and check if we don't have secret dot secret string let's return null there's nothing to parse. Otherwise, let's try and return json parse secret dot secret string as t. Else, let's return null. My apologies, not else catch. There we go. So now we have a simple method which can help us read the value of the get secret value command output. So when we call this get secret value, we're going to get it back inside of JSON Stringify. So in order to work with it we going to use this little util to help us turn that secret string into an actual object and we are going to be able to type that object thanks to this little extension so however we use this function we're going to be able to give it specific types so for example parse secret string and then in here we're going to be able to do id or let's say public api key string like this. And then the return of this function will be public API key string, strictly typed. That's what this part does. It allows us to dynamically set the return type. Excellent. We now have our secrets library. Now let's go ahead and let's create an internal action for working with secrets. So inside of system, go ahead and create secrets.ts. Let's import v from convex values. Let's import internal from generated API. Let's import internal action from generated server. and let's import our newly created Upsert secret from libsecrets. Let's export const Upsert internal action, arguments, organization ID, service will be a type of union, and the only option available here for now will be puppy. and the value will be any. Now let's add the handler here which is going to be an asynchronous method like this. Now in order for this to actually make more sense it would be better if we paused here and created the schema for our plugins. So let's quickly go inside of convex schema.ts and just above the conversations, let's add plugins. Let's go ahead and call the find table and inside, let's add organization ID to be a type of string. Service of this plugin will be a union and inside, you would add all the services you support. In my case, that's only going to be Vapi, But I'm building this with keeping in mind that you might want to add multiple plugins, right? Because take a look at how I've designed this. I've made it so it's ambiguous. Imagine if this is Google Calendar, right? Then it will say Google Calendar integration. Here, a little image of Google would stand and you would click connect, right? So that's what I'm keeping in mind. You would probably want to extend this somehow. So that's how we are building this, right? But right now, the only thing we support is WAPI. And one more thing we need here is the secret name. So the secret name will be used to access our secrets.ts lib here when we call get secret value. So later, when we want to read from AWS Secrets Manager, hey, what were the API keys for this organization who stored their WAPI keys with us? And then we're going to return back, hey, these are the keys that you have stored inside of AWS. And then we're going to parse them so they are proper objects. So now that we have the plugins set here, make sure you have organization ID, service and secret name. And now let's start by adding the index by organization ID. And then let's add another index by organization and service. and actually, I would like this to be, let me see, I just want to be consistent. So by contact session ID, yes, I want to put this by organization ID and service. I think that is more consistent with the way I've named my other indexes here. Great. Now, let's go ahead and do TurboDev. This way, we can watch our convex dev run and see if we have any errors with our schema or with any functions we have written so far. There we go. Added table indexes and convex functions ready. All good. Now we can go back instead of our internal upsert action here. Inside of here, the first thing we're going to do is we're going to get our context and the arguments here. And I don't think I have anywhere else used organization ID like in the param name except here in the secrets right especially if I try this v.string yeah everywhere else it is organization id exactly so organization id let's be consistent so first things first let's generate the secret name how do we name our secrets well the answer lies inside of our AWS actually. If you remember inside of our Echo Tenant Secrets Manager Access we created an ARN and I'm not really sure how to like read it properly here but we made it a wild card that has to be used in a form of tenant and then asterisk after that If you remember, if you don't, you can quickly go back to the chapter to see what I'm talking about. But basically, that's how we need to build our secret name, tenant. And then we're going to pass arguments organization ID. And then we're going to add arguments.service. So this will end up being tenant1234 as in organization ID or tenant ID, right? We are using the word tenant within AWS. And then vapi. and maybe in the future Google or cowl.com or Vercel or whatever you might do. In our case, this is how it's going to be. And now let's call await absurd secret, secret name and value. Let's go ahead and pass arguments.value here like this. And let's return status success. and now let's go ahead and also create the plugin record in our database because right now when the user calls this action well user will never call this action because it is internal but even when we call it sure aws will receive this new secret under this specific tenant name but we are never actually going to store the secret name inside of our table so we will never know how to retrieve it back. So let's go ahead and go inside of convex and let's go inside of system and create new file plugins.ds. In here let's import v from convex values. Let's import internal mutation and internal query from generated server. And let's go ahead and do export const absurd internal mutation. The arguments will be service, which is a type of union and accepts for now only VAPI. secret name will be a type of string and organization ID will be a type of string as well. Now let's add the handler and let's go ahead and get context and arguments. First, let's find if we already have a plugin. So existing plugin, await context.database query our newly created plugins with index by organization ID and service. Let's go ahead and grab Q here and let's go ahead and check if organization ID equals arguments organization ID and if service equals arguments.service. So we are specifically looking for a database record for a unique database record with this organization ID and this service. There should never be two records like that. If we have an existing plugin, in that case, let's go ahead and do a wait context database patch. Existing plugin underscore ID, service arguments dot service, secret name, this is super important to store, secret name arguments.secret name. Else, let's go ahead and do await context database insert into plugins. And we're creating one for the first time. So we need to pass the organization ID as well. And then we can just copy service and service name from above. Just like that. Perfect. Now that we have this, let me just add this. And let's build one more internal function here. GetByOrganizationID and service. InternalQuery. Const. The arguments here are going to be organizationID and service. Let me just quickly copy this again. And then we're going to have the handler. Let's grab the context and the arguments here. And let's simply do return await context dot database query plugins with index. And we can just copy from here. like this. So by organization, IT and service,\norganization ID, service, arguments, service. Perfect. Exactly what we need. Now that we have this, let's go ahead and let's go back inside of our secrets.ts right here in the convex system folder. So we've just upserted this secret where we generate the secret name. And now, since this is an internal action, we cannot directly access the database. So what we have to do is await context run mutation internal system plugins absurd. And then we pass in the service arguments service secret name and organization ID arguments organization ID. Just like that. Now we have synchronized both the AWS and our internal database. Perfect. Great. Now that we have this, we are ready to start building the components. So let me just go ahead and mark these as completed. Secrets lib, plugins schema, secrets functions. Now we need the VAPI view and plugin card component. let's go ahead and let's build the vapi view so inside of apps web modules let's go ahead and create a new module plugins inside let's create ui views and let's add vapi view.tsx let's go ahead and mark this as use client and let's export const vapi view and let's return vapi view. Now, let's go ahead inside of web app dashboard, plugins vapi page.tsx and let's modify the return vapi view. Just like that. Now, when you go to localhost 3000, you should have it running so web dev should be in localhost 3000 and in here when the app loads and you click inside of voice assistant here you should see vapi view this is what we're going to be developing now if for whatever reason it's not working remember you have dashboard sidebar and in here you have the links plugins vapi for voice assistant that is this and now this all right so in order to build this we're going to have to build that plugin card but I just want to start by first adding a div class name flex minimum height of screen flex column bg muted and padding of eight then inside I'm going to create another div mx auto full width and the Maximum screen MD. Then let's go ahead and create another div with a class name. Space Y2. H1 element. Vapi integration. Actually, let's use the word plugin, not integration. Because integrations will be something else in our app. Text to Excel MD text for Excel. below that connect WAPI to enable AI voice calls and phone support. Let's go ahead and give this a class name text muted foreground. And in here let's go ahead and add a class name margin top 8 and let's go ahead and simply add to-do plugin card. Let's see our app and how it looks like. This is how it should look like. And now let's develop the plugin card. So the plugin card will actually be a reusable component for, again, whatever services in the future you might want to have. Instead of the plugins module, inside of the UI folder, create components. And let's create plugin-card TSX. Let's import arrow left right icon, type lucid icon and plug icon, all from lucid react. Let's import image from next image. Let's import button from workspace UI components button. Let's export interface feature here because we will pass a list of features inside of these plugin cards. So what are features? This. This will be controlled outside of this component. We're going to pass them as props, as an array of features that some plugin has. So each feature will have an icon, which is a type of Lucid icon, label, and description. And make sure to export that interface. Now let's create an interface plugin card props with option is disabled, which is an optional boolean, service name, which is a string, service image, which is a string, features, which is a type of array of features, and on submit, which is an arrow function. Perfect. Now let's go ahead and let's export const plugin card. Let's go ahead and reuse the plugin card props here. And let's go ahead and let's destructure all of them. So is disabled, service name, service image, features and on submit. In here, let's go ahead and return a div with a class name, height fit, full width, rounded large, border, BG, background and padding of 8. Already, you can go inside of Vapi view and you can just render the plugin card. Ignore the errors for now. But at this point, you should already see a white box here. Now let's continue developing the plugin card. so I'm going to add a div inside with a class name margin bottom of six flex items center justify center and gap six inside of this div I'm going to add another div with a class name flex, flex column and items center. Now let's add an image which is a self-closing tag with an alt of service name, class name, rounded object contain, height of 40, width of 40 and source service image. Right now if you try you will see a broken image. But just for fun, you can pass in the service image here to be logo SVG and you can pass the service name already to be Vapi. Great. Now let's go back inside of the plugin card. Outside of this div, let's go ahead and create a new div and do arrow left right icon. And let's go ahead and give this a class name flex, flex column, items center and gap one. perfect and now let's go ahead and add we can actually copy this entire div paste it here and this will be your platform right so platform it doesn't need this and the source will always be logo svg so now we're just going to have two of the same images right So let me just go ahead and quickly add the Vapi logo into my assets so that you can replace it and so that this image makes more sense. So head to my Echo Assets repository. You can see the link on the screen here. And inside of the public folder, you will have vapi.jpg. And then go ahead inside of your apps, web, public folder, and simply paste it inside or drag and drop it. And now let's go ahead and go inside of our Vapi view and replace this with vapi.jpg. And there we go. Now this makes more sense, right? So we are connecting Vapi with our application. Perfect. So let me just see. Let's go back inside of the plugin card here. So we continue the development. After this, and after this div actually here, let's create another div with a class name margin bottom of six and text center inside a paragraph with a class name text large span inside connect your service name account. let's go ahead and check that out. There we go. And in fact, span is not needed here. I had an idea, but it's okay like this. Outside of this div here, let's create our feature list. So another div with a class name margin bottom of six. Inside another div with a class name space Y4. and then in here let's do features.map and for each feature let's go ahead and let's return a div with the class name flex items center gap 3 key feature.label another div inside like this with a class name flex size eight items center justify center rounded large border background muted and inside feature dot icon a self tag with a class name size4 and text Right now, nothing will be visible. In fact, we're going to have an error. That's because our type here is incorrect. So let's go ahead and let's pass the VAPI features. So this is how I'm going to do that. I'm going to add const VAPI features right here. And I will use the feature type from plugin cart. So the first one will be icon globe icon from Lucid React label web voice calls. and description is going to be voice chat directly in your app. Let's go ahead and let's pass in the features here. Vapi features. And now you should be able to see the icon. Just like that. Now let's go back instead of the plugin card and let's finish the features list. So outside of this div, another div, and in here we're going to render feature.label with a class name font medium text small let me just fix the typo here copy that change this to feature description and this one will be text extra small and text muted foreground just like this. Now I'm going to add the rest of WAPI features. So back inside of WAPI view, I'm going to add all the icons, globe icon, phone call icon, phone icon, and workflow icon. And then I'm just going to show you each feature one by one. So the next feature after web voice calls, we're going to have phone numbers and a description matching. And now I'm just adding some features to populate this plugin card. So phone call icon, outbound calls, automated customer outreach. Basically, all the things you can do with WAPI, right? One more. Workflow icon, workflows, custom conversation flows. Save this. And now this looks much better. Very attractive to add this to your app. Perfect. And now let's go ahead and go back inside of the plugin card. And now outside of this div here, create a new div with a class name, text center, add a button, which we already have, inside connect, and plug icon. let's go ahead and give this the class name size full disabled is disabled on click on submit and variant default just like that we have finished our plugin card reusable component and you can now reuse this to create all the future plugins that you want to have in your app great so now let's go ahead and pass in some more things so we have service image we have service name is disabled let's explicitly add it to false for now and on submit let's also explicitly add it like that so what would be the easiest thing to do now well what I want to do right now actually is I want to be able to load a plugin. In order to load a plugin, we are already halfway there because we have the schema for our plugins and we have the plugins.ts internal functions to create them. And we can also fetch them. But we don't really have a function that will allow us in here to call useQuery, VAPI plugins, and then so we can check do is disconnected or not, right? Because this is obviously disconnected state. But if we manage to load VAPI plugin with this organization ID, this should change into the actual, well, fetched phone numbers and voice assistants, right? so let's go ahead and leave the WAPI view as it is now and let's go back inside of our packages backend convex and inside of private let's create plugins.ts let's export const get1 which is going to be a type of query and it accepts a service which is a type of union literal WAPI let's go ahead and let's import query from generated server let's import convex values let's add an asynchronous handler context arguments now let me quickly go inside of messages here for a very simple reason so I can copy the identity check and the organization check let's quickly go back plugins private paste this here let's import convex error from the values here like this and now once we've confirmed we have organization id let's simply return await context.database query plugins and then we simply do the index we've already done before by organization id and service comparing the organization id with the org ID and service from arguments.service that we pass in here. Make sure it's unique and that you are returning that. Great. And while you are here, actually, we can go ahead and do something more. Let's copy this entire thing and just above here, paste it and rename this to remove and instead use mutation from generated server. the check is the same identity is required organization ID is required but instead of returning this we're going to do const existing plugin like this and then if there is no existing plugin throw new convex error code not found message plugin not found so we can't delete it and now let's do await context database delete existing plugin underscore id and let's return existing plugin underscore id. Actually, we don't have to return anything. So now we also have our remove mutation here. Perfect. Now we can go back inside of the WAPI view. And in here we can get the WAPI plugin by using useQuery from convex react. And in here we can pass in the API from workspace backend generated API. API.private.plugins.get1. And in here, let's go ahead and pass service, Vapi. So if this is a type error for you, it means you don't have your backend running. So just make sure you have backend running and no errors here. So now we're going to be able to know do we already have existing WAPI plugin or not? So when will this be disabled? It's going to be disabled if WAPI plugin is undefined because that is the loading state in Convex when you use useQuery. So now whenever you refresh this page, for a brief second you will see how it is disabled because it's still loading that query. Perfect. So now in here we can do if we have WAPI plugin. In that case, we're going to display one thing. Otherwise, we're going to display plugin card. So in here, I'm just going to say paragraph connected like this. But for now, since we don't have that record in our database, it's always going to be this integration form. What we have to do now is we have to develop the actual integration form. In order to do that, we're going to need a couple of states. Connect open and set connect open. Let's call use state and pass in false. Let's import use state from React. And now let's also import everything we need from the dialog. So in here, dialog, content, description, footer, header, and title. Let's also import everything we need from form. Form, form control, field, item, and message. Now, let's also import input and label components. Then, let's add Zod. and let's add React hook form and let's add our hook form resolvers. And just because I already promised you a couple of chapters, let's go ahead and let's add the toast component. We already have the toast component. We just have to go inside of apps, web, app, layout and in here, render toaster. Let's go ahead and import toaster from workspace UI components toaster. My apologies, Sonar. Like this. Now that you have the toaster, you will be able to toast everywhere. So yes, you have the Sonar package already installed. inside of packages UI But now we have a small problem here While we can add this toaster instead of our app layout if you go back instead of vapi view and for example in here you want to import toast from Sonar, right? You don't have Sonar here because this is a different package. If you search for Sonar, you can see we have it but inside of packages UI. So now let's copy this version and let's do pnpmf web add sonar at and then this specific version and this will then allow us to have the same version inside of the web package I mean web app and now no more errors here so we can finally use sonar now great now let's go ahead and let's develop the form schema here. So after a VAPI features, let's add form schema, which will be a ZOD object, which accepts public API key and private API key. Both of these are going to be strings. And let's go ahead and give them minimum one with message public API key is required. and then we can copy this, add it here. Private API key is required. Great. Now, let's go ahead and let's export my apologies. No need to export anything. We can just do const wapi plugin form. The types this component is going to have is going to be open, which is a Boolean and set open, which will control the value, which is essentially the open value, right? Open, set open. Now in here, let's go ahead and use our absurd secret from use mutation from convex react, API private secrets.absert. And is it possible that I forgot to do this? I thought we had all the functions we need looks like there is one more that we need my apologies so let's quickly add this absurd inside of packages backend convex private go ahead and create secrets.ts and do we have anything similar okay let's just go ahead and do it so export const absurd is a mutation the arguments that is going to accept are service and value the handler is going to be an asynchronous method let's import the convex values make sure to add literal vapi here because that's the only one we support right now as always let's go ahead and just copy from the plugins here our identity and organization ID check let's import the convex error from the values. And now once we do this, let's go ahead and add to do check for subscription. Because we shouldn't be able to do this if we don't have subscription. And now let's go ahead and let's upsert our secret. Except we have a small problem. How can we do that? How do we call internal functions within mutations? So this is just a way for us to call this using useMutation from the WAPI view here. But the actual logic for creating AWS secrets lies inside of convex system secrets. Remember? Absert, which accepts organization ID, service, and value that we are trying to save. In here, we generate the secret name. we store this inside of AWS using the absurd secret from our secrets lib and then we also save this to the table to the database so the way we have to do this is by using await context scheduler run after zero as in immediately internal make sure to import internal from generated API.system.secrets.absert and simply passing the service to be arguments.service, passing the organization ID, this will be org ID, and passing the value arguments.value. Let's just check if I did this correctly. So we have the value. Oh, I completely ruined this internal. So I think these types should work, right? If I forgot to add something, I have an error. If I add a typo, I have an error. Perfect. So it's strictly typed. Make sure that you have passed the service, which is WAPI, organization ID, so we can build a secret key, and the value, which will be from here, from our form schema, public API key and public and private API key, but we're going to stringify them, right? Because when we receive that here, absurd secret, we're going to stringify that object. So it will be stored as a string. Perfect. So you might have a question here. Why not just use useAction? Why did I abstract absurd behind the mutation? So we are kind of doing half the work here and then calling this. Well, for one reason, I feel like Secrets, Absert is more reusable this way because Absert definitely has to be an action because it uses this Absert secret, which is third party. This entire thing is third party. So it has to be an action. But the reason I've labeled this as an internal action, because I feel like this makes it way more reliable, right? If we ever want to create an update form, we can just easily create another mutation and then schedule the internal upsert like this. Another reason is because using useAction, while completely okay, is labeled inside of Converse documentation as an anti-pattern. So that's why I'm trying to write as good code as possible here for you. So I wrote an internal action here, and then I'm wrapping it within mutation, which is exposed. And I know that kind of mixing this internal stuff, actions, mutations, and queries is a little bit complicated. So in case you're confused, it's perfectly fine. These are new concepts, right? Convex is a very specific runtime. It's super powerful, but it takes time to learn and understand. So if it helps, you know, you can write down some flows, the differences between the two, but the thing that can help you the most is their documentation. Whenever you feel stuck, have no idea what I'm talking about, go ahead and search mutation, convex documentation, internal action. Why do we need action? What's a third party? All of those things, right? Great. Now we have the absurd mutation here, which calls our previously created secrets here. now that we have that let's go back in here and let's go ahead and let's define the form we've already defined the form constant i think five times already so constant form is use form z.infer type of form schema from above zoda resolver form schema default values public api key and private API key. Perfect. Now let's develop const on submit method. This is going to be values z.infer type of form schema again. It's asynchronous. Let's go ahead and open try and catch method here and for the first time let's use toast.error something went wrong and let's also actually do console error simply so our users can report like hey i'm seeing errors in the console and i mean yeah i'm not sure how smart of an idea this is but at least in the development it might be a good idea for you to see the entire error in the console and now let's do await absurd secret here pass in the service that we want to save which is WAPI and then the value it can be anything in our case public key data public my apologies values public API key private key values private API key actually let's store them like this public API key private API key so no point in not calling them the same All right. And now we have this onSubmit method. And now let's go ahead and let's return dialogue. Let's go ahead and pass onOpenChange to be setOpen and Open to be Open. Let's add dialogueContent, dialogueHeader, dialogueTitle, enableWapi. outside of header let's add dialogue description and in here let's just add something descriptive like your api keys are safely encrypted and stored using aws secrets manager and then in here let's add our form let's spread the form let's use normal form element give it class name flex flex column gap y4 and let's pass on submit form handle submit on submit form field is a self-closing tag which has control form.control so again this is just chat cn and react cook composition here the first field will be public api key Let's go ahead and destruct field here. Form item label public API key form control input And let spread the entire field And let go ahead and set a placeholder here your public API key And the correct type here would be password. And let's add form message below it. now we can copy this entire form field paste it this one is going to be private api key your private api key private api key there we go and now still inside of still inside of form here add dialog footer render button inside we have to import it first so make sure you have added the button from workspaces UI components button if form form state is submitting write connecting otherwise connect disabled if form form state is submitting type submit. Now we have to render it and we have to enable it on click. I mean on click of our connect button here. So let's scroll down inside of Vapi view. Wrap our entire app inside of a fragment so we have semantically correct positioning of our components. and let's add WAPI plugin form. Let's pass in open, connect open. Let's pass in set open, set connect open like this. And now let's develop const handle submit. If we have WAPI plugin, we're going to set remove open, which currently doesn't exist. So let's quickly just create it. remove open, set remove open. This will be for removing the connection. So set remove open, set to true. Otherwise, set connect open, true. Like this. Make sure to pass connect open and set connect open to the WAPI plugin form here. And in the plugin card, in here, let's simply pass handle submit. As simple as that. When you click connect now, you will have a new model which allows you to add your API keys. So, we now have to test if all of this is actually working. Let's try it out. What should happen first? Once I add some keys and click connect, what should happen is that inside of my data here, inside of plugins, I should have my first record. The second thing is I should have a new secret inside of AWS. So just for easier visibility, I'm going to change the type to text here for my API keys. Just for us, so it's easier to develop. I will say for the public key, public 123, private 321. And let's click connect. and now you can see immediately this was connected but one thing that i don't like is that uh this didn't close so let's quickly fix that inside of vapi plugin form on submit here after successful upsert set open to false and we can also do toast success vapi plugin uh should we do it yeah, let's do WAPI secret created. That's technically what happened. All right, so now, as you can see, I already know the record here is created because it says connected. You can see how it still tries to load it initially, and then when it loads, it says, hey, I have this record here, plugins. And you can see how in our database, we don't store any API keys. So if someone breaches our database, we are not storing any sensitive information. And if they grab the secret name, they also have to obtain our AWS access key, AWS secret access key, and our region. And keep in mind, we can just change this. We can just remove this. So we are protected. And this is fine, but the real question is, do we have it inside of here? So let's go ahead and go inside of our dashboard here and type in secrets manager. Go ahead and click here. And let's see if this works or not. So I will probably have a bunch of them here. But let me go ahead and try clicking on the latest one. You don't have all of this. I have them because obviously I was developing this app. But let me try clicking on the latest one, assuming that this is the newest one. and in here you can click retrieve secret value keep in mind that you probably have to be logged in with your root user account for that let's click retrieve secret value and here it is private api key private 321 public api key public 123 exactly what we stored was encrypted and stored securely inside of AWS Secrets Manager. And using this secret name and our combination of AWS keys here, we will be able to up cert and retrieve those API keys and we are successfully one step closer to white labeling this entire thing. What an amazing job you've done for this chapter. and what an amazing job you've done in the previous chapter. Everything works. In the next chapter, our job will be to display the phone numbers and the AI assistants that we developed in chapter 6, if you remember. So we are now going to load Tom and the phone number we created and this will officially allow every single customer of ours to create their own assistants, their own phone numbers, their own knowledge base. Amazing. Now let's go ahead and merge this. So 24 Vapi plugin. And yeah, we can test one more thing though. Instead of Vapi view, yeah, try and do this. Always allow plugin card. and for now always open set connect open in the handle submit so this is important right and now even though we have api keys we can now change them so i will do change one two three change private three to one so change public one two three change private three to one let's click connect. Again, we get the Vapi secret created, but this is actually a successful upsert action. So now in here, as you can see, nothing has changed because it's still the same organization and the same service. So nothing here has changed. These plugins didn't even update, I think. It's still the same secret name. The only thing that should update is inside of AWS Secrets Manager. Again, you should probably only have one. I have a bunch of them. Select this one. Let's click retrieve secret value. Change private 3 to 1. Change public 1 to 3. What an amazing job you've done here. Excellent. So I will now just bring all of that back. Handle submit should look like this. And this should be conditional. So now inside of here, you should see connected. Amazing. 24 VAPI plugin. Let's review and merge these changes. I'm adding the commit 24 VAPI plugin. Let's click commit. I'm going to go ahead and create a new branch. 24 VAPI plugin. And I will publish the branch. And I'm very, very curious what CodeRavit will think of this PR. I think we did a really good job. But I am interested to see what a professional says. So let's go ahead and create this pull request and review it. And here we have the summary by CodeRabbit. We introduced a plugin integration UI for connecting and managing the WAPI service, including a secure form for submitting API keys. We added a plugin card component to visually display external service connection options and features. We implemented global TOS notifications for improved user feedback throughout the app. The WAPI plugin page now provides an interactive view for connection status and management. As per the infrastructure, we added backend support for securely storing and retrieving plugin secrets and managing plugin records. And in here, we have the sequence diagram explaining exactly what happens. So let's go through it here. When the user clicks connect and submits API keys through the WAPI view on the front end, we call the absurd secret mutation with our API keys using the backend API. Then we call the absurd secret from our secrets lib, saving them inside of AWS Secrets Manager. After confirming via success or failure, we do the same with our database record. And then we simply show toast notification and update UI. That's exactly what we did. And CodeRabbit left a lot of comments. 15 comments. Now, most of these comments are simply improving errors, such as this one. One thing we forgot was switch back to type password here. You can see how security concern private API keys should be masked, definitely. So we are going to fix that here. In here, it doesn't exactly know our future plans. So it doesn't know that we're going to have the remove option, which will also have its own submit button. And we will use this currently unused state variables, right? So I'm going to skip through that here. and we will develop the proper page for that. And now in here it's recommending checking if we have the API keys. I'm fine with the way this is right now because this will fail either way if we don't have those. For the secrets here, it recommends introducing all the other types\nof error handling because we are only handling the resource exists exception. But honestly, I'm okay with just throwing for all others. I don't need to explicitly handle them. But yes, this is obviously a good comment, right? I'm just saying for the simplicity sake, I'm just throwing all other errors. You should definitely look into handling all of them individually. and now it finally got fed up with me adding this identity and organization check every single place here so it recommended that I should probably reuse it validate user and organization we could definitely extract that somewhere and then reuse it and then it left a couple of comments like that same thing as this where I use as a string so it finally got fed up of doing that so it's recommending me to refactor all of those things but in here it actually recommended me a very interesting thing so this is an upsert which means either create or patch but we should never allow the organization id to be changed but i currently don't check for that so i could check for that and throw the error if i detect that the submitted organization id is different from what was currently saved in the plugin because technically another organization could hijack API keys from someone else's organization. So this is very, very good oversight here. Perfect. In here, again, it suggests better error handling. Same thing here. But overall, I don't think there was any serious security issues. There is this thing that I definitely want to do, not enable the organization ID to accidentally change during patch. I still don't think this can happen maliciously, but still, we should prevent this from happening. Even though in patch, we don't even pass this. Okay, okay, yeah, I will look into it. Yeah, I don't think it can be malicious, though. I think it can just be in some super-duper edge case weird behavior. But I don't think it can be malicious, but very good comment. and let's go ahead and merge this now once we have confirmed the merge let's go back here change this to main branch and let's click on synchronize changes push and pull and once this has synchronized as always I like to double check with my graph here and I can see uh oh did I 22 and I just called this 20. Oh yes, no, I'm correct. It's 23 was just setting up AWS Secrets Manager. I thought that I skipped a number, but it didn't actually because 23 was our AWS Secrets Manager setup. So we officially pushed to GitHub. Amazing, amazing job. We have merged this and see you in the next chapter. In this chapter, we're going to implement a WAPI connected view displaying the WAPI data. So in the previous chapter we created the WAPI plugin functionality allowing the users to bring them to bring us their API keys which we store in AWS Secrets Manager. And just to discuss one thing from the previous chapter there was a potential security issue at least I thought but I didn't fully read the message So CodeRabbit here actually suggests adding the option to change organization ID. But in our case, organization ID should never change for an existing plugin. That should never happen. But in here, they say, alternatively, okay, if you want that, then add a check for that. But I looked at our code and we don't need that. So this is inside of backend convex system AI plugins.ts. And in here, if existing plugin exists, they say then check if organization ID is different and throw an error. But look at what we do just moments before. We use the organization ID index to fetch the same existing plugin. So we are essentially already doing that. Because this will simply be null, undefined, if organization ID is missing or if it's different. and then it's just going to create a new one as it should. So our code is 100% fine for this comment right here. Just wanted to clear that up. Now let's go ahead and let's actually allow our user to see all the models and phone numbers that they have from their connected Vapi account. In order to do that, we have to start by adding the Vapi AI server SDK to our app. And yes, just a quick reminder, try turbo build every now and then. So I just tried it after our previous chapter and I can still build normally. It's always good that you try turbo build so you catch errors early on. Let's go ahead and do pnpmf backend add and let's add whoops that's not it. Let's add at vapi.ai forward slash server SDK. And once you've added this, let's go ahead and let's run TurboDev so that we have convex dev running because we're now going to add some new methods. Let's go inside of packages, backend, convex, private, and let's create vapi.ts. In here, let's go ahead and import vapi client and let's import vapi from our newly added package. Let's import internal from generated API. And since this will be calling a third-party API, which is vapi, let's go ahead and import action from generated server. And in here, let's import get secret value from libsecrets and parse secret string from libsecrets. because this is where we are going to fetch our saved API keys for this organization and then we're going to initialize the VAPI client using that. Let's export const get phone numbers first. So this is going to be an action. Arguments are going to be empty. Handler here will be like that. Let's add context and arguments here. Let's go ahead and do our usual check here. this way we confirm that the user is logged in and that they have matching valid organization id and now let's go ahead and let's fetch the plugin to see if it exists so that we can obtain the secret name from the database because remember what we have to fetch is this from our plugins database here we have to find the plugin so that we can get the secret name and using that secret name, we can call getSecretValue and that is going to return the values here, private API key and public API key. So let's go ahead and do that now. Once we've confirmed that we have the identity, we're going to fetch the plugin. But since this is an action, we can only do that using the internal methods. So let's define const plugin await context run query internal dot system plugins get by organization ID and service. That's right. We already have that. We developed it previously. You can see it right here. If you don't have it, just in case, it's inside of the system folder. So system, you should have it. Plugins get by organization ID and service. The organization ID is org ID. Let me just fix the capitalization. And service is WAPI. There we go. In case plugin is missing, let's throw new convex error here. Code not found. And message. Plugin not found. Meaning that we cannot fetch phone numbers for this. and now let's go ahead and let's get the secret name which will be plugin.secret name so we can fetch the secret. Const secret will now be await get secret value and pass in the secret name. Now this will be a type of get secret value command output so in order for us to destruct the data secret data we have to use our parse secret string. And in order to make this strictly typed let's go ahead and define what we expect to be saved here. So what do we expect to be saved inside of here? Well we can easily check that by simply going inside of the secret value and we know private API key and public API key so we can add it here. This and public API key. and inside of here pass in the secret. Let's call this secret value, like this, secret value. That makes more sense. But usually the way you would know what is the expected type of the secret data from the value would be in the place where you save that, right? So I think the only place where we actually strictly type this is inside of the WAPI view component, inside of here. The form schema actually defines what it is. AbsurdSecret, this is the value that we store, public API key and private API key. Because inside of AbsurdSecret itself here, we simply define it as any. And that's correct, right? We don't know what this will be used for. This can be for WAPI, This can be for five other plugins, right? And they might have different names for there. They might have five API keys for all we know. That's why we don't type it here. But it's important that we know, at least in the VAPI view, in the form, when we call AbsurdSecret, what is the value? So public API key and private API key. And that's the same thing we're fetching now from here. And we can also double check via the actual Secrets Manager here. and now let's check if we were not able to get our secret data we can go ahead and throw the error meaning okay even though you have the plugin record we were just not able to find this in AWS So let go ahead and say secrets or let say credentials not found And now let's go ahead and let's check individually if we have both of these keys. So in case we are missing secret data dot private API key, or if we are missing secret data public API key, in that case, let's go ahead again and let's throw credentials incomplete. Please reconnect your VAPI account. and now we can finally define the vapi client here using new vapi client and usually what you would do here is you would do token process.environment vapi secret key right that's how this works that's how you would usually do it but we already explained the problem behind doing this in the chapter six the problem is Vapi has a shared knowledge base per API key so in order to allow our customers to have their own knowledge bases which are private and secure as well as their own phone numbers their own assistance workflows and tools we need to allow each of them to bring their API keys instead of using our API keys. So now instead of doing this, we are simply going to do secretData.privateApiKey. As simple as that. And now that we have that users of API client, we can fetch that user's phone numbers. Like this, await vapiClientPhoneNumbersList. And let's return phone numbers. There we go. And let's see if this function will work. Seems to be working. Let's check once again. Perfect. Convex functions ready. Amazing. Now let's go ahead and let's copy this. And well, let's paste it above here. We're now going to do the exact same thing, but we're going to fetch assistance. So let's go ahead and rename this getAssistance. Assistance, like that. Same things here. But instead of fetching phone numbers, it's going to be assistance, like this. I don't think there's anything else we have to do. So pretty much the same. Perfect. and we actually don't need the arguments here at all and we don't need the arguments in the one below either perfect now that we have that let's also confirm this all works perfect let's go ahead and let's create the hook to use these two so the problem with these is that they are actions but we need to use them as if they were queries. So the way we're going to do that is by going inside of app, web, modules, plugins and let's create a new folder called hooks and inside let's create usewapi.ts and now let's go ahead and import use action from convex react. let's import useEffect from React useState from React let's import Toast from Sonor and let's import API from Workspace backend generated API let's exportConst useVapiPhoneNumbers and let's go ahead and define the return method here actually we don't have to I think we can just go ahead and define some states here. So data set data will be use state. But it would be helpful to have the type of this data actually. Let's say type phone numbers type of API private vapi get phone numbers underscore return type and then assign phone numbers here like that. then add is loading set is loading use state and true by default then error set error use state a type of error or null and set it to be null as default now let's get the get phone numbers through our use action and let's call api private vapi get phone numbers and now let's grab our use effect method here and first things first let's fetch the data so let's create an asynchronous method here let's open try catch and let's open finally inside of try set is loading to be true and in the finally let's reset it back to false inside of catch let's grab the error here and let's set error error as error and let's toast.error failed to fetch phone numbers. In the try method, let's go ahead and grab the result by using await get phone numbers from the action defined above. Set the data to be the result and you can see the result is a type of phone numbers list response item. So we could also use that. I'm directly using the literal return type of the API call but this seems to be working I think because if I change this to like string yeah then you can see this doesn't match but if I keep phone numbers then it matches yeah okay let's just continue like this now and let's set error to be null and now let's call fetch data within this use effect and let's pass in get phone numbers here I think that's the only thing we are using and what we are returning will be the data is loading and error so our small little version of react query hook right and let's see so use what the phone numbers cannot be named without a reference to this. So what is this? This is a kind of odd TypeScript error that I encountered exclusively in Monorepo. So I think this is because of the Monorepo architecture. The fix is quite simple actually and I also got this error later at the end of this chapter we're going to try turbo build i actually also got this error in my convex functions except they weren't typed here so the error in here actually is that i have to do the same thing that i have to do here when if i resemble a third party app i have to add a return type of that because in here we are returning vapi.assistant right so i should be doing the same thing here. So the way you can fix this is by modifying the return type here and strictly specifying it. So data will be phone numbers, is loading will be boolean, error will be a type of error or null. And you can see that this actually fixes it. So it needs to have a return type. and now that we have this let's go ahead and add assistance get assistance and let's copy this entire thing let's paste it here this will be use of api assistance it will use the assistance this type will be assistance it will call get assistance and let's rename this and let's rename the error message. So they are identical, right? Except calling different API endpoints. Perfect. We now have these two useful hooks for us to work with. And I'm actually super interested in this. So I want us to stop now and I want us to try turbo build. So you can see that I tried turbo build just before this chapter and I'm super interested if now I will get some errors maybe because I kept getting a failed builds because of this exact error that was happening here which we just fixed by adding a return method but I got them here in my convex. This was the problematic part. it seems like everything is normal for me here. But just in case, I will show you how I fixed it in case you are getting those errors. So inside of here, get assistance. What I did was I added promise and then I simply described what we return. And that's Vapi. phone number numbers list response item and then an array of those my apologies this is true but not for this exact action for the one below so let's copy that here and let's assign it here there we go so forget phone numbers this is the promise that we return you can see how no errors regarding that that's perfectly fine now let's go ahead and fix this for the assistants here. So this will be very simple, vapi.assistant. Just like that. So now we have added explicit return types for our actions, which are returning direct third-party libraries So let me try TurboBuild again just to confirm that this didn accidentally mess it up But yes when I was initially developing I kept getting errors here because of that until I added this Only then did it start working again. An alternative way to fix this is by literally adding any type of explicit return method. The problem is with not having a return method. You can see that in here, I didn't even reference the VAPI API. I'm literally referencing my convex return type here. Still works. Perfect. Let's go ahead and do a through both dev here. And let's continue developing on our way. Now that we have these two hooks, it is time for us to create the VAPI connected view component. So we just did this and we just did this. let's now go back inside of our apps web modules plugins ui and in the components let's create a vapi connected view let me just fix this connected let's go ahead and add all the imports that we are going to need starting with use client and after that let's import all the icons bot icon phone icon settings icon and unplug icon after that let's add image and link from next then let's add use state from react let's add button from workspace ui components button let's import card description header and title from our card component and let's do the same thing for tabs including tabs content list and trigger now let's create the interface for this component it's going to have one prop on disconnect which will be a required method so vapi connected view props now let's go ahead and let's quickly define and export this component like this. Vapi connected view has only one prop. In here, I'm going to set the active tab and set active tab using state. And by default, it will be phone numbers. And then in here, let's go ahead and let's return a div with a class name space Y of six. Let's add card, card header. let's render div here and the class name flex items center and justify between let's go ahead and render another div here with a class name flex item center and gap four now in here let's go ahead and let's add an image and let's do alt vapi class name rounded large object contain height 48 width 48 source voppy jpeg and let's leave it like that. Now let's quickly go back inside of our voppy view here and in here where we conditionally render connected let's add our new component voppy connected view and on this connect let's add handle submit because this will then open the remove dialog if we have the WAPI plugin. So now if you go inside of your localhost 3000 here let me just refresh so my component here gets compiled and we should see our new component that we are building. There we go. That's exactly what we've built so far. So continuing inside of Vapi connected view here below this image let's add a new div. Oops let me add a div. Let's add card title Vapi integration. Let's go ahead and add card description and inside of here something descriptive about what we can do with this dashboard. So manage your phone numbers and AI assistance. Now right here, go ahead and add a button, disconnect. Give it an unplug icon and give it a class name. Actually, we don't need any class name here, I think. Unplug icon is enough. and give this an on click of on disconnect and size of small and variant of destructive. There we go. And now you have an option to disconnect your VAPI plugin. And I think it would be a good idea to actually implement this right now because we already have everything we need here. So we're gonna go ahead and go back inside of the VAPI view here. and it should be quite simple to implement this. Let's go and let's copy our Vapi plugin form and let's go just below here and paste it. And this one is going to be called Vapi plugin remove form. It will have open, set open. instead of up cert secret we're going to use remove plugin use mutation api private plugins dot remove we already have it perfect remember when we added it i told you to do it right away because we are going to need it here and now we actually won't need form at all that's the great part so instead we can just do remove plugin and service WAPI because the rest of the information like organization ID is all coming from auth. After that set open to false, WAPI plugin removed and the catch can stay the same and the dialog will be a little bit simpler so this will be disconnect WAPI and for the dialog description. Let's add something simpler here. Are you sure you want to disconnect the WAPI plugin? And then you can remove the form entirely. And let's go ahead and just add dialogue footer. Let's add button, disconnect, on click, on submit, variant, destructive. So keep in mind how this will not remove the secrets from AWS Secrets Manager. It will simply remove the plugin. So my choice for this was, I feel like it's safer not to remove AWS Secrets just in case the user accidentally disconnects because this is technically just a disconnect option. It isn't a remove values option. I feel like semantically this is correct. I don't know. Now let's add vapi plugin remove form. Open will be remove open. Set open will be set remove open. As simple as that. And instead of calling this handle submit, let's call this toggle. We can just call it, yeah, toggle. Toggle connection. and passing on disconnect, toggle connection, and in here on submit, toggle connection. And now when you click disconnect, you will get a prompt. Are you sure you want to disconnect the VAPI plugin? And carefully look here, and you will see that when I disconnect, the VAPI plugin has been removed. I no longer have any data here. But my secret has not been removed, right? So I still have access to this last secret here. You can see it's right here. and then if the user decides to connect again so reconnect 1, 2, 3, reconnect 3, 2, 1 and click connect what we've just done is we created a new plugin in our database but we didn't spam this API endpoint for AWS so you can see I don't have any new secret which wasn't retrieved before so it's not possible that a new one was just created Instead, this is the exact same one from before because the secret here is exactly the same. Vapi and that organization ID. Reconnect 3 to 1, reconnect 1 to 3. Exactly what we wanted. Amazing. And now we can go back inside of Vapi connected view and develop it further. So we just finished that card actually. We no longer have to develop anything in this card. Now let's open a new card. Card header. let's add a div here whoops let's go ahead and give it some classes here class name flex items center justify between another div class name flex items center whoops my bad flex items center gap four inside of this div let's add one more div with settings icon inside give its outer parent div a flex size 12 items center justify center rounded large border and background color of muted so now you should see your settings that looks like this and for the settings icon let's give it class name size 6 text muted foreground perfect now outside of this div open a new div and inside let's add the card title and the card description same as above so we can just add it here and this will be widget configuration and in here we will see set up voice calls for your chat widget and then outside of this div here we're going to add button as child link So we are using as child because then this button component will become an anchor component And the href will lead to forward slash customization which is a page we yet have to develop and that will be the settings page and we actually don't have to add anything and just configure so now we can see that we are basically telling the user who just connected this WAPI plugin great in here you can connect whether it's connected or not connected but if you actually want to add this to your chat widget you have to go inside of configuration which will redirect the user here which we don't have yet perfect exactly what we wanted and now let's develop the actual tabs which will show phone numbers and all the other things so outside of this card here let's add a div class name overflow hidden rounded large border bg background let's add tabs now the tabs here will have the following props class name gap zero default value phone numbers phone dash numbers all value change set active tab and value active tab keep in mind that this phone numbers needs to match exactly without a typo this use state phone numbers inside of here let's add tabs list now i'm going to give this a class name grid height of 12 full width grid columns of 2 and padding 0 inside of this tabs list i'm going to go ahead and add a tabs trigger inside phone icon and write phone numbers inside. I'm going to give this tab's trigger a class name of full height, rounded none, and value phone numbers. Now let's go ahead and let's copy this trigger here and change this to be bot icon and change this to be assistance, AI assistance. and the value will be assistance. And now you have phone numbers and assistance to switch to. Outside of the tabs list, let's add tabs content with the value phone-numbers. And inside, let's simply write to do phone numbers. Let's copy this, change the value to assistance, to do assistance. and now you can switch between the two. Perfect. Now let's go ahead and let's develop the Vapi phone numbers tab and once we develop it, we can literally copy it for the assistance tab. So inside of the UI components, let's create Vapi phone numbers tab.tsx. Let's mark it as use client. let's go ahead and import all the icons that we are going to need so that's going to be check circle icon copy icon external link icon more horizontal icon phone icon and x circle icon after that let's import toast from sonar then let's import badge from workspace UI components batch. Let's import button from workspace UI components button. Let's import the drop down menu content item and trigger and let's import the table. Table body, cell, head, header and row. And finally let's import use WAPI phone numbers from our hooks useWAPI data that we've created previously. There are not going to be any props, so let's immediately do WAPI phone numbers tab. And in here, let's destruct data and let's remap them to phone numbers and let's grab isLoading all from useWAPI phone numbers hook. Let's define copy to clipboard method. text string and actually let's make this asynchronous let's open try, catch toast error, failed to copy and in here await navigator clipboard write text and then toast success copied to clipboard now in here let's return a div class name border top bg background table table header table row and then individual table head here which will say phone number then in here class name px6py4 font medium actually we don't need font medium let's go ahead and copy this table head Change this one to be name. Then this one to be status. And the last one will be actions. Change the actions one to use text write. Outside of table row and outside of table header, add the table body. Inside of here, open curly brackets. Open parentheses. Execute an empty function inside. and then execute that again. So this is a self-invoking function. We are practically simulating a component inside because what this will allow us to do is the following. We can now do if isLoading, return a specific thing, table row, table cell. Oops, let me just end table row. Table cell. and inside loading phone numbers. The table cell will have a call span of 4, so it's taking all the 4 columns that we have. And it will have a class name, px of 6, px of 6, py of 8, text center, and text muted foreground. the next thing we're going to do is we're going to copy this and we're going to check let me just see did I do this correctly okay we have to end this if here and then it's correct okay this will be if phone numbers.length is equal to zero. In that case, we're just going to say no phone numbers configured. And finally, let's return the phone numbers. So return phone numbers.map. Get the individual phone. Add table row here. Class name. Hover BG muted with 50% opacity. Key phone.id. Table cell, class name, px6py4, font medium. Actually, no need for font medium. Div, class name, flex items center, gap three. Inside, phone icon. Let's go ahead and give it class name, size four and text muted foreground. And next to it is span phone.number or not configured. And let's render this under font mono. Outside of this table cell, let's go ahead and let's copy it actually like that. And this one will be simpler. it's just going to render not phone number but phone name or unnamed. Let's copy the table cell again. This one will be different. So this one will say badge. We're going to have a class name capitalize. And we're going to have different variants. So variant, if phone.status is active, we're going to use default, otherwise destructive. So we know the status of the phone. And then phone.status is equal to active. In that case, check circle icon, class name, MR1, size 3. go ahead and copy this and do the same for if it is not active then use x circle icon and then finally render phone dot status or unknown like that and then the last one here we need is the actually we won't need actions at all because the way we are going to develop our widget customization, yes. So we don't need the actions and that means change the call span here to three and here to three as well. And that would mean that we are done. Perfect. Now let's go back inside of here to do phone numbers. Let's render a WAPI phone numbers tab like that. and by default we are going to get errors why are we getting errors well the reason we're getting errors is because we have fake api keys we can't fetch anything so let's go inside of our vapi.ai you can use the link on the screen let's click open dashboard here and just remember if you don't have vapi configured chapter six go ahead and take a look at it in here we build the customer support agent and we have phone numbers and things like that. So let me just log in.\nand here I am and now just make sure that you have at least one assistant so I have Tom and Riley here and inside of your phone numbers confirm that you have at least one phone number here perfect now that you have that go inside the WAPI API keys the last thing we actually did is we deleted all API keys because we stored them in securely so now let's go ahead and add key here and I'm going to call this echo public key and I won't select anything else just create public token and then I'm going to okay let's just create another private key so echo private key and again I'm not going to select anything just create that perfect and once we have that let's disconnect here let's refresh for good luck and let's click connect now I'm going to copy my public API key from here and I'm going to add it in the public field. Yes we have to turn this into password field I forgot about that and let's do the same thing with the private key here and now let's click connect and just like that we have working phone number fetch from Vapi. this is the magic of bring your own keys method and this is the magic of our white labeling method right so this will allow every single one of our customers to connect their own vapi and load their own phone numbers i honestly think this is amazing because we do so many things we store the database table as plugin but we actually store it inside of aws secrets manager and we managed to encrypt that and decrypt that and it's super secure and it's super cool and it works. It's amazing. And this tells us the status of the number. Is it ready to use or not? And the exact name, perfect. Now let's go ahead and do the same thing with AI Assistants so that we can finally wrap up this chapter. So the cool thing is that we can just copy WAPI phone numbers tab and rename it WAPI assistance tab. Now, inside of WAPI assistance tab, we can actually remove the unused drop-down menu here. Unused button, unused three icons here. Let's go ahead and change this to use WAPI assistance. WAPI assistance tab. The data here will be remapped to assistance. We don't need copy to clipboard also. And now let's work from here. So in here we're not going to have phone number, we're going to have assistant. We're not going to have name, we're going to have model. We're not going to have status, we're going to have first message of the assistant. Perfect. So loading assistance, assistance.length, no assistance configured like that. Now in here, we are iterating over assistance. And now let's go ahead and check, well, field by field. The first one is going to be, yes, this is also assistant. So assistant.id, bot icon instead of phone icon. Let's import that from Lucid React. This will use the assistant.name or it's going to be unnamed assistant. And no need for font mono here, but everything else can stay the same. Now in here, we are going to do assistant dot model question mark model, because that's how you access the name. So not configured. Otherwise, if we can't find it. And let's wrap this inside of a span here. And let's give it a class name of text small. Like that. And then the last one we're going to do here is not nearly as complicated as the badge. Instead, we are going to add a span again and render assistant dot first message or no greeting configured. and let's give this a class name truncate text muted foreground and text small like that and I believe that marks the end of this component too let's remove all the unused things to these three icons this and this let's go back instead of vapi phone numbers and also remove all the unused things. So copy external and more horizontal button. This we're also not going to need toast. You can remove it and remove copy to clipboard. There we go. Perfect. And now let's go inside a connected view and let's add VAPI assistance tab self-closing. And there we go. Tom and Riley as well as their first message here let me just check great calls to did okay now this is fine VAP the assistance that I was hoping that the message would be truncated maybe paragraph instead of span no, maybe line clamp one. Maybe it's not a problem. I don't know. Not sure why it's not listening to me because this is the exact code that I used and it did work. Okay, I think we need to do maximum width of extra small. Yes, we need to limit how wide this can go. There we go. And now it will have a limit. But you can choose. If you like it being displayed, it scrolls nicely it's fine and now you can see all of your assistants connected with WAPI and your phone numbers amazing and using this method you can also add tools outbound workflows whatever you want your users to be able to use WAPI for which is absolutely amazing you will be able to add here using the exact same method I'm super happy with how this turned out and what we have to do next is develop the widget customization which will allow us to select what phone number and AI assistant to use on the chat widget screen. Before we wrap up the chapter let's quickly go back inside of VAPI view and let me just properly give our type here password so we finally fix that issue. Amazing. So let's go ahead and go back to here. We officially did this, right? The users can now bring their own API keys. We store them securely and every single tenant now has their own knowledge base, phone numbers, assistant tools. Perfect. And they don't even occur any costs on our side of the app. They are responsible for their costs of the WAPI. Amazing. Let's go ahead and mark this as completed as well and let's merge these changes. 25 WAPI data. Let me go ahead and stage all of my changes here. 25 WAPI data. Let's commit. Let me open a new branch. 25 WAPI data. Let me publish the branch. And as always, let's go ahead and review our changes to see if we had some oversights and overall quality of our code. And here we have the summary. We introduced a user interface for managing WAPI integration, including separate tabs to view and manage AI assistants and phone numbers. We added tables to display lists of connected assistants and phone numbers with detailed information and status indicators. We enabled plugin connection and disconnection via dialog interfaces with masked API key inputs for security. And we added toast notifications for success for all failed plugin actions. Amazing. So I think that the sequence diagram is pretty clear to us, right? We already know what we're basically doing. If tab is assistance, we use use WAPI assistance and the backend calls get assistance. And then WAPI API fetches those assistance. I think that is pretty clear to us. In here, we have various comments. And this is an important one. Yes, we should absolutely have a variable like this. Because imagine if you are in the middle of fetching data and quickly switch to a different tab. That will cause set state set in an unmounted component warning inside of your console. So yes, this is super important for us to do. I will do that in the next chapter. Same thing here in the assistance. And also it recommends not adding the function inside of the dependency array because it will run on every render since use action returns a new function reference each time. Very interesting. I will look into that as well. Perhaps we don't even need anything in here and then it will just be a simple on mount function. Very, very good comments here. And now the rest of the comments in here are mostly error handling, right? Of course, it recommends handling errors in a better way, handling them inside of our tables. If you want to, you can of course do that. We have the error object so you can display it then. That I'm mostly okay the way it is right Now, in here, it recommends refactoring the duplicated code because we have get assistance and get phone numbers. And yeah, the code inside is identical in like two functions, a pretty large chunk. So we could abstract it technically, except if you want to throw different errors, I guess. Yeah, we could do that. We definitely have to start abstracting some code. I'm repeating myself too much, but it is super simple code. It's nothing complicated. In here, again, it recommends handling errors in a better way. We could do that too. But since we are using that as queries but yeah I mean obviously handling errors is never a bad idea I will look into all of that in the next chapter In here it made a mistake Version 10, 0.10 is definitely the newest version. I think its registry has some incorrect information here. But super, super useful for us to know that we have potentially broken hooks here that can cause maximum update depth or some other errors. So we will fix that in the next chapter. Let's go ahead and let's merge this pull request here. And then let's go ahead and switch back here into main. And let's push and pull to synchronize the changes. Once the changes have been synchronized, let's go ahead and click graph and confirm. So we went 22, then 23 was AWS, 24-wise WAPI plugin and 25 WAPI data. Amazing, amazing job. I believe that marks the end of this chapter and see you in the next one. In this chapter, we're going to implement a widget customization form. Let's start by fixing the use WAPI data hooks from the previous chapter. If you remember, CodeRabbit left a useful comment here. One for adding the cancelled variable so that we clean up async operations and prevent memory leaks. We have to do this for both of our hooks. And the other one was to remove the get assistance and get phone numbers dependency since that could potentially cause an infinite re-render. so I want to make sure we don't run into those issues first. I'm going to go inside of web and let's go inside of our modules, plugins, hooks, use of API data. So first things first, let's remove the get assistance from here and let's remove get phone numbers from here. now let's go ahead and let's add this so i'm going to add on the example of use wapi assistance here inside of the use effect let me just see let's go ahead and cite let cancelled to be false and then go ahead instead of try after we successfully get the result. If cancelled, let's go ahead and do an early return. And I prefer writing if clauses like this. I don't like the inline ones. In the error, let's do the same thing in the beginning here. Basically, why are we doing this? The point is not to do this after await. The point is to do this before we set data or before we set error. Because error or data are the results of an asynchronous operation. Set is loading is not a result of an asynchronous operation. So we only have to do it to prevent setting state on a component that has unmounted. That's why we are doing this. And in the finally, let's go ahead and only set this if not cancelled. So if we are still continuing to fetch this as expected. And finally, let's simply just add an unmount function here, which will set the cancelled to be true, I believe. Let's see. Exactly. Yes. And that's how you safely clean up your functions here. Now let's do the exact same thing. We just did it for use VAPI assistance. Now let's do it for use VAPI phone numbers. so inside of useEffect here instead of useWAPI phone numbers we set letCanceled to be false after that before we set the result let's go ahead and early return if we have cancelled this method let's also do an early return before we throw an error here and let's only set this loading if we have not cancelled. Like so. And then in here, let's return cancelled true. There we go. Now, both of our hooks here should be more robust. They shouldn't cause infinite re-renders and they should be cancellable, meaning that we don't have to worry about memory leaks. This is actually quite important to do and this is why people always choose to use React query hook. Did I? React query, that's the name of the package because they handle that internally. We could technically add it here as well but I don't know, we need to add the whole provider and everything and it's only these two examples where we need it for other places we can use the use query from Convex so I'm not 100% sure if it's worth it. And yeah, for this get assistance, yeah, I'm not 100% sure if that's memoized or not. Because if it's not, then yes, it will cause infinite rerenders here. Same thing for get phone numbers. I have a feeling Convex thought of this and they would make it memoized. But just in case, yeah, we technically don't need it in the dependency array. It will still be accessible here. It just won't update. So save this. Let's go ahead and run Turbo Dev. And let's go inside of our Web Dev. And let's go ahead and refresh our plugins Vapi here. And there we go. Phone numbers are loading. Assistants are loading. And I don't see any problems in here. Excellent. And if you accidentally switch while something's loading, you won't get that overflow, right? So this is the problem, right? Imagine if this voice assistant took you five seconds to load, and in the middle of those five seconds, you switch to knowledge base. What would happen is that, for example, this hook will try to set data and then try to use it, but it was unmounted at this point. That's why we had to add the cancel to fix that potential issue. Excellent. Now let's go ahead and let's build this, the widget customization. And in here, we're going to be able to basically allow user to select their chosen phone number and their chosen assistant, which is what we actually tell them right here. Like go inside of your settings if you want to add that. But before we can do that, we actually have to go inside of our convex schema. And inside of schema, we actually have to add widget settings. So let's go ahead and add widget settings, define table. Let's go ahead and start by adding the organization ID, which is a type of string. Let's add greet message, which will basically be the message that your chatbot will greet the user with. And let's add default suggestions. This will be a type of object. and let's add suggestion one to be an optional string, and then do that two more times. So we are going to allow a very simple structure to allow three suggestions. If you want to, you can modify this to be infinite suggestions, but honestly, only a small number of this looks good in a chat box, right? I don't know if it makes sense to add more than three. And let's also do WAPI settings here, which is an object, assistant, ID, optional, string, phone number, whoops, optional, string. There we go. And now we have the widget settings that we can modify and load later on. and that's how we're going to allow an organization to customize their chat box widget. For example, if they want their chat box AI to greet users with hi there, they will be able to modify. If they want hello, they will write hello. And some default suggestions like commonly asked questions or things like that. And in here, pretty logical, the assistant ID and the phone number they want to use. After that is done, as always, double check in your workspace backend that you have that schema updated, that the functions are ready. Great. And one important thing here is to add an index. So index by organization ID. And let's use the organization ID field. That's the only index we need. Make sure that this adds an index here. Let's just wait a second. There we go. Index has been added. Excellent. Now let's go ahead and let's create the customization view. For that, we're going to go inside of apps, web, modules, and let's create a new module called customization. Inside, let's go ahead and UI views customization view.tsx. Let's mark this as use client. Let's export const, whoops, customization view. and let's return a div customization view. Then let's go ahead inside of apps. Let's go inside of dashboard, customizations, customization page and in the return customization view. As simple as that. And now in the widget customization, you should see customization view component being rendered. and now we can focus on that right here. So the first thing I'm going to do is I'm going to get my widget settings using use query from convex react and API from workspace backend generated API and in, oh, I actually didn't create the functions so we can't do anything but yeah, now we have to create the widget setting functions. I went ahead of myself and created the front end but yes we have to go inside of back and we have to go inside of convex private and let create widget settings dot DS and let go ahead and do export const get one query let's import the query arguments are going to be empty handler is going to be an asynchronous method inside of here as always we have the context we have we don't need arguments since we don't have them now let's go ahead and just do our usual identity check here and organization check where are my widget settings here they are so confirm that the user is logged in and the user has active organization if you've created a util feel free to use that util right and now let's go ahead and very simply do const widget settings and let's do await context.database query widget settings with index by organization id query query equals organization id organization id and return widget settings just like that make sure you're back in this running so you have those new changes. And now in your customization view, you can use API, private, widget settings, get one. And actually, yeah, no arguments needed because you can only fetch your current organization settings. It's not externally controlled. And now in here, let's go ahead and do JSON stringify widget settings. Oh, we have some errors here. Yes, we have some errors in the widget settings we need to also add unique here my apologies after you do that your functions should now work there we go now let's go ahead and refresh to see if the errors got fixed and there we go by default it's null because well i have no widget settings available at the moment. Great. So now what I want to do is I also want to fetch my Vapi plugin, but actually I might want to leave that for later because right now in this chapter, I only want to focus on building these settings that we just defined in the schema. Then later, we're going to add Vapi plugin as well. Let's go ahead and check if widget settings is equal to undefined and in that case let's go ahead and return a div class name minimum height of screen flex flex column items center justify center gap y2 background muted and padding 8 inside of here let's add loader2 icon with the class name text muted foreground and let's do animate spin and let's add a paragraph loading settings and let's give this a class name text muted foreground and text small and now when you refresh this page for a brief second here you should see loading settings like that excellent and now that we have our loader screen let's go ahead and let's add the actual component here and now we're going to add to this div a class name flex minimum height of screen flex column vg muted and padding 8 instead of json stringify widget settings let's go ahead and let's limit how wide this screen will go using maximum width screen md mx auto and full width now inside of here. Let's add a div. Let's go ahead and give it space Y2. And we're now going to use the exact same thing that we did inside of our, let's see, VAPI view, I believe. So head inside of your VAPI-view. And in here, scroll down, make sure that you're looking at the VAPI view component. and in here we have the same thing you see h1 and the paragraph inside you can copy that that's the div and now you should see vapi plugin here now let's just change the text so this will be widget customization and in the paragraph here let's see customize how your chat widget looks and behaves for your customers. There we go. Now outside of this div, let's create a new one. Let's go ahead and give it a class name, margin top of eight. And inside of here, customization form. And inside of the customization form here, let's pass in initial data to be widget settings like this. Instead of your customization plugin, UI folder, go ahead and create components. and inside of here create customization-form.tsx. Now inside of here let's prepare the usual. Well let's also add useClient here. Actually we don't need to add useClient because the view itself has useClient. So let's import Zod resolver. Let's go ahead and import Z from Zod. let's import use form from react hook form let's import toast from sonar let's import button let's import everything we need from our card component including the content description header and the title and let's go ahead and import everything we need from form that will include form control description field item label and message now let's go ahead and just add some control fields so that's the input we're going to need the separator to make it look good I think this is the first time we're using the separator it's just like any other components from our workspace UI components and let's add text area because we will have some larger fields to fill here and before we go any further I don't want to develop this until I actually create the widget settings absurd function. So let's go ahead and quickly do that. So we can focus on the customization form here. But let's also focus on widget settings, private convex function here. And just as we've created get one, let's go ahead and copy this now. And let's go ahead and do absurd. This will be mutation. So make sure you change that. and inside of the arguments for this mutation, you will basically be able to modify everything that you've defined in the schema. So greet message, let's go ahead and import convex values. Besides greet message, you will also be able to modify all the default suggestions. So these ones, always double check this with your schema, right? Basically, we can literally copy from here and you will be able to modify the WAPI settings. the only thing you won't be able to modify is the organization id now in here you now have the arguments we do the identity check we do the organization check and now in here we fetch the widget settings and we are still going to need to do that let's just change the name of this to be existing widget settings because they're going to be used for a purpose of checking if we have widget settings or not. And let's simply do if existing widget settings. In that case, let's await context database patch. Existing widget settings underscore ID, greet message, default suggestions, and WAPI settings. And let's, of course, do arguments. Can I just pass in the arguments here? I can. Perfect. because they're identical, right? I don't have to... Usually we wouldn't do that because in the arguments we'd have some identifier like organization ID, right? But in this case, literally the entire values of our arguments are the values of this. You can see if I just modify a slightest thing like this, I think I'm going to get errors here. I'm not getting errors. yeah okay that kind of worries me I thought that it's going to be strict with that but it's not okay then let's actually do it one by one rather than that so greet message arguments greet message default suggestions arguments default suggestions wapi settings arguments wapi settings and else let's go ahead and let's do await context database insert widget settings organization id is the org id of the current user greet message is arguments greet message default suggestions are arguments default suggestions and WAPI settings are arguments WAPI settings. There we go. Now we have our absurd function and we can go back inside of the customization form. Now let's go ahead and let's simply create the widget settings schema for all widget settings. so those are going to be z.object greet message z.string with a minimum value of one greeting message is required default suggestions which will be an object of three items inside and wapi settings which is an object of assistant id and phone number each option inside of default suggestions and inside of wapi settings are optional the only thing required is the greet message. Now let's go ahead and create type widget settings and let's remap that to document, import that from workspace backend generated data module and map it to widget settings so we don't have to type this every time. Now let's create interface customization form props, initial data, widget settings or null Let export const customization form And let assign customization form props initial data There we go. Now in here, let's go ahead and let's return div form. Now we can go back inside of the customization view component and we can import the customization form. And you shouldn't have any type errors here. Now, I do want to slightly just go back here. And I also want to import WAPI plugin using useQuery, API, private, plugins, get1, service, WAPI. Let me see. Okay. Vapi like this. Then let's also mark this if Vapi plugin is undefined. We can actually extract this. And then use is loading. So it's clear what this is doing. and now that we have that the only reason I'm using it for is so that I can pass it here so has VAPI plugin and let's simply go ahead and do the following VAPI plugin. Now let's go back inside of customization form and let's go ahead and let's add the new prop which is hasWAPI plugin and we're going to make it a required boolean and now we can focus on this instead of here the first thing I want to do is create upsert widget settings use mutation and import use mutation from convex react and pass in API import API from workspace back and generated API. And then in here, API.private.widgetsettings.absert, which we've just created. Now let's define the form, use form. And let me just create the type form schema to be z.infer type of widget settings schema. Now I have the type form schema and I can just easily add it here. Let's go ahead and let's add a resolver. Zod resolver, pass in the widget settings schema and for the default values, set the greet message to check if we have initial data question mark dot greet message or use hi how can I help you today then for the default suggestions suggestion one initial data question mark default suggestions dot suggestion one or empty string and now do this for suggestion 2 and 3. For the VAPI settings, similarly, add Assistant ID, Initial Data, VAPI Settings, Assistant ID, or Empty String, and same thing for Phone Number. And that's how we define our initial settings. now let's go ahead and let's create our on submit method so on submit will be an asynchronous method which accepts values type of form schema let's open try let's open catch in the catch let's do toast.error something went wrong and for development mode let's also have the error here in console error, the error. Instead of try, let's go ahead and do const vapi settings first so we process them correctly. So the assistant ID will be data vapi settings, my apologies this isn't data, it's values dot assistant ID is equal to none. So why am I checking if it's equal to none? because we're going to add our select field. And in this select field, we're going to allow our user to select literally value none. And this will be used to kind of reset if they just don't want to use an assistant ID, right? So they can have VAPI connected, but maybe they just want to turn it off for whatever reason. So that's why I'm checking. If they specify value none, to the backend, I'm not going to send literal string and none because to the backend, that is an ID. That's a value. So I know that that's going to be just an empty string. Otherwise, values, vapi settings, assistant ID. And can I maybe use widget settings, vapi settings type in here. So if I misspell something I get an error. I recommend you do that as well. Then copy this and change all of these instances to phone number and this way we allow our user to reset values of the assistant ID and phone number if needed. And now we can do await absurd widget settings greet message arguments my apologies values greet message default suggestions values default suggestions and wapi settings from the constant above after that toast success widget settings saved widget settings saved great now let's go ahead and let's develop our form. So in here I'm going to use the form which we imported and I'm going to spread the form. Now in here I'm going to add a native form element with a class name space y6 on submit form handle submit on submit. Then I'm going to add card, card header, card title, general chat settings. Then card description, configure basic chat widget behavior and messages. outside of card header I will add card content. Card content will have form field which is a self-closing tag and let's just add card content space y6 class. The form field is going to have a prop control of form dot control. It's going to control the value greet message and in order to do that we have to render an input field. So let's composite, let's do the composition for this. Form label, greeting message, form control, let's just misspell, fix the typo and we're going to use the text area self-closing component. Spread the entire field inside, give it a placeholder of welcome message shown when chat opens and give it rows three and already you will see that we have this initial placeholder in case user didn't type anything hi how can i help you today now let's go ahead outside of form control let's add form description here the first message customers see when they open the chat. And form message. My apologies. Yes, form message. This will display the errors if there are any. Now let's go ahead outside of this self-closing form field and let's render the separator component like this. So now we have the separate. Now let's open a div. Let's give the div a class name space Y4. Let's open another div to enclose some elements here. Let's add an H3 element default suggestions. Let's go ahead and give this a class name margin bottom four and text small. Now we have the default suggestions heading. Inside of here, let's add a paragraph and let's quickly describe what suggestions are for. Quick reply suggestions shown to customers to help guide the conversation. Give this paragraph a class name of margin bottom of 4, text muted foreground, and text small. There we go. And now in here, outside of this paragraph, another div with a class name space Y4. And instead of here, you can copy this form field. And you can paste it inside of the first space here inside of this div. Let's go ahead and just indent this. This one is going to control default suggestions dot suggestion one. So let's add suggestion one. And instead of using text area, it's going to be using input because this will be a shorter message. And let's do, for example, how do I get started? And no need for the description. It's already pretty descriptive. And now let's go ahead and let's copy this form field. Let's paste it below. This will be suggestion 2. Suggestion 2. And let's go ahead and copy this form field.\nAnd in here you can write something else just to make it look better. What are your pricing plans? And then let's do suggestion three. So I copied this again. Suggestion three here. And let's go ahead and do I need help with my account. There we go. Quick reply suggestions shown to customers to help guide the conversation. now let's go outside of this card here and this will be vapi settings but only if has vapi plugin so if we don't have vapi plugin we we're not going to show this so let me just extract this prop because we added it additionally so you need to add it too if we have vapi plugin let's go ahead and add card and let me just copy my card header from here so I don't repeat myself instead of general chat settings this will be voice assistant settings and in the description here let's go ahead and do configure voice calling features powered by Vapi So now this will only be visible to you if you have connected your voice assistant. So if you disconnect, you will not have this widget settings. So I would highly recommend that you have this connected. In case you forget your API keys, you can always just head to AWS. Let me just log in. so head inside of the secrets manager in here the latest one will be well the latest one you added so click on your secret and click retrieve secret value and in here you have your private and public api key so you can see if i click disconnect here and remove my plugin and go inside of widget customization widget settings are not visible. But if I then go ahead and connect and let me just copy the private API key and add it into the private API key field and do the same thing with the public. This way you don't always have to have the dashboard open. There we go. If the numbers can fetch you configured correctly and you can now go inside of your settings and there we go you have a voice assistant settings. So make sure you have that so you can actually see what we're developing here. and now outside of this card header here let's go ahead and add card content let's go ahead and do class name space y6 let me just see what part was not indented correctly this is not indented correctly and this and for now let's do to do vapi settings because I just want to wrap up this form by adding it a div and adding a button save settings and giving it a disabled prop if form form state is submitting is true and give this a type of submit and let's give this a class name flex and justify end and now in your settings down here you will have the save settings button and this should already work even if this isn't developed. So what I suggest you do is go to convex.dev. Let's log in here and let's specifically go inside of our dashboard. So echo tutorial here. Instead of your data, again if this is closed, sometimes it can look like this. So you can just click on tables and it will open. Find widget settings and right now there are none. So if I go ahead and change this to Hulu and change this to Suggestion 1, Suggestion 2 and leave the 3 empty and click Save Settings, Widget Settings Saved. If I refresh, let's see, it's working. I don't even need to check the database because if I can refresh and the settings stay, it means they are loaded correctly. But yes, we can see them here. suggestion one, suggestion two, greet message, organization ID, and you can see how WAPI settings are just empty because we haven't done any. So if your absurd data is correct inside of the widget settings, it should work. If something's not working, double check your absurd mutation here and double check your get one mutation as well. Maybe something's wrong there. Or if you're not seeing values here, it could be that inside of your customization form, you have forgot to fill the default values, right? Make sure you're using the initial data that you pass from the customization view, widget settings, right here. Great. Now let's go ahead and let's develop the UI for adding the WAPI plugin form fields. So since this is a pretty large component and we are going to need to load WAPI numbers again, I want to separate this in its own component. So inside of components here, create vapi form fields.tsx. And now let's create an interface vapi form fields. Vapi form field props. Form will be a type of use form return from react hook form and pass in z.infer and then type of and okay this is what we're going to do first import zod then go back inside of customization form and let us export form schema and then you will be able to just add form schema from dot forward slash customization form and you no longer need zod so this is a type I'm not sure if this is maybe a circular import now I think it's okay worst case scenario you can just copy the entire widget settings schema and let's add disabled optional boolean now let's export const wapi form fields let's go ahead and let's assign wapi form field props Let's get the form. Let's get disabled. And in here, the first thing we're going to do is fetch our WAPI assistance from our hook, which we have optimized in the very first few minutes of this chapter. Let's get the data, which we can remap to assistance. And is loading, which we can remap to assistance loading. Let's duplicate this. Let's do use phone numbers. Use WAPI phone numbers from the same import. phone numbers, and let's do phone numbers loading. Perfect. Now let's go ahead and let's return an empty fragment. And inside of here, we need to again add our form elements. So let me just go ahead and let's add form control, description, field, item, label, and message. But besides that, we also need our select element. Select content item trigger and value. once you've done that you can go inside of your customization form and just copy a form field so it's easier so we don't have to write it all over again and let's go ahead and let's add the form field within these fragments here let me just fix the indentation and what we're going to control first is WAPI settings.assistantID. So let's change this to be voice assistant. Instead of text area, we're going to be using select. So remove the placeholder and remove the rows. Now select works a little bit differently. For starters, it is not a self-closing component. And it's not going to have all the elements of the field. It will only have field.value. and it will have on value change. So field.onChange here. It's going to be disabled if assistance is loading or if the form is disabled overall using this prop right here. But the composition behind this select is a little bit different. So for now, you can actually remove the form control and remove the description and remove the message. So just have form item and then form label and then select here. And then inside add form control. And then select trigger. And then select value. Now the select value is actually a self-closing tag. Which is going to have a placeholder which will depend. If assistants are loading, in that case it's going to be loading assistants. Otherwise, it will be select an assistant. And then let's go ahead outside of form control and let's add select content and let's do a composition for that. So this is just composition of how you build select components within a form using Shatsune UI, right? These are just the rules as simple as that. In case you're wondering why am I not explaining why this is outside of form control. I don't know why it's outside of form control. It's the rules, right? It probably has some sense, but I don't really learn that much in depth. It's enough for me to know how I have to do it and maybe check the documentation if I mess it up. But I don't go into such depths of learning exactly why select content composition has to be outside of form control. I'm sure there's some logical explanation, but I just want to get it to work. So instead of select content, we add select items. select item. First one will be our none option with the value none. And I hope now it's clear. We're going to allow the user to select this. This should render now. Why is it not? Oh, because we are not passing the WAPI form fields. Let's do that. Instead of the customization form down here, we have to do WAPI settings. Let's now do WAPI form fields and passing disabled Actually we don need disabled prop because we have the entire form prop here So import wapi form fields And inside of here we don't need disabled because we have the form and we can just define our disabled. const disabled form form state is submitting. There we go. All right, now that you have that rendered, you will see that you have an option to select none. And if you click Save Settings, inside of our onSubmit method here, in the customization form, you understand why we had to do this. We have to check if the user selected none. We shouldn't submit this literal string to the database because in the database's eyes, this is a completely valid value, right? We know that that means empty string. But as far as I know, select component doesn't offer an option to do this. So we have to add one option ourselves and manually control its value. So that's why we did that whole thing. Great. Now we have this select item. And now let's render the rest of the select items, which can be as many assistant as the user has. So assistance.map, get the individual assistant. And inside of here, render the select item, assistant.name or unnamed assistant. And then let's go ahead here and let's add a space. And let's render assistant.model, question mark.model or unknown model. and in here give it a key of assistant.id and value of assistant.id. And now you will see that you're able to load Tom and Riley and their respective models so you can differentiate between them. Perfect. And outside of this select here, add a form description and simply write the WAPI assistant to use for voice calls and form message to display if any form errors are here. Perfect. And now we can literally copy this form field and we can paste it and we can simply modify it for the phone numbers. So WAPI settings phone number, this will be display phone number this will be phone numbers loading loading, do I have it? do I not have it? phone numbers loading I'm having a typo somewhere but I don't see it phone numbers loading, there we go loading phone numbers select a phone number. This will be unknown and this will be unnamed. We are not iterating over assistance. We are iterating over phone numbers here. So this is a phone. So phone.id, phone.id. And for the value, we can actually use either phone.number or phone.id. And in here, check if we have phone.number instead of name, and then it's going to be unknown. Or, I mean, in the second option, it's phone.name or unnamed. And this description will say phone number to display in the widget. And there we go. you can now select your assistant and you can select your display phone number and click save settings and if we've done this correctly let's first refresh to see if we've done it correctly we did as you can see so inside of here now if I go ahead and click in VAPI settings you can see how we store the exact assistant id and then you can see how this will help us because remember we have this use VAPI which we created in chapter six I believe where we demonstrated how VAPI works and remember we have to pass the API key which we now have because we store it inside of plugins and then we have the secret name and then we load AWS and then we have the customer's API key But one more thing we needed was Vapi's assistant ID. And we now have that as well. So this hook is almost ready to be used again, this time with our customers' information instead of ours. And that will mark the end of our white labeling journey, which I say was very exciting to do something completely new we've done on this channel. Excellent. so I believe that's all I wanted to do in this chapter here. One thing I'm a little bit concerned about is importing this form schema from customization form which then imports the WAPI form fields and I have no idea how this import got here. We don't need it. I am kind of concerned about this. We will see if CodeRabbit has anything to say about this. Okay, I'll just do some research myself just for a second. Or, you know, I don't need research. I know what we can do. Let's just copy the widget settings schema. And inside of customization here, let's go ahead and let's create constants.ds. Go ahead and paste widget settings schema here. Import Zod. Maybe rename this to schemas just like that. So in here we keep widget settings schema and in the customization go ahead and create types.ts import widget settings schema from our newly created schemas and export type form schema z again from zah.infer type of widget settings schema. there we go and now we have this form schema now instead of our customization form remove this remove form schema and use the form schema from types and use the widget settings schema from schemas there we go our newly created fields inside of the customization module here so that's the customization form fixed and now instead of vapi form fields we no longer have to do this circular import we can just import from types there we go i think everything should still work perfectly fine this was only settings change after all let's change this and let's change this to riley and let's change this to none and change this to something new save settings refresh everything works amazing amazing job i believe that marks the end of this chapter so we added we fixed wapi data hooks we added widget settings schema functions customization view customization form and now let's go ahead and review our changes so this is 26 widget customization. I'm going to go ahead and stage all of these changes. 26 widget customization. Let's commit. Let me open a new branch. 26 widget customization. And let's publish the branch. now let's go ahead and let's open a new pull request let me just go ahead uh i just have to uh do some things for this branch to appear here there we go 26 widget customization let's create pull request and let's wait for the review to start so I thought that instead of using the pull request review how about we use the code extension review instead so I'm going to go ahead and click review all changes using the free code rabbit extension which you can install as well of course you don't have to do this I just want to see if we made any crucial mistakes because code rabbit really does catch a lot of mistakes that we do. So this time, instead of using it in the pull request here, I have stopped it here. So I'm going to use it via the CodeRabbit extension instead. You can find the extension by searching for CodeRabbit and it's a completely free AI code review. And you just have to connect your account that you're usually using for GitHub. and here we have the code rabbit review so don't worry i will not do any changes here i already told you to start a new branch and i already told you to open a pull request so don't worry i will only use this as a guide on what to do in the next chapter but we do have some mistakes here starting with the first one in the vapi form fields here i have a completely incorrect loading state variable as you can see. I'm using assistance loading where I should be using phone numbers loading. So for now I'm just going to reject the changes even though they are completely correct simply because I don't know if you already opened your branch. I don't want to make this complicated for you. I will just remember it for the next chapter. Excellent. So that's one error here. Let's see add null checks and error handling for data fetching here. I'm not sure if we have to do because of the nature of how these hooks work from the inside because I think they are already safe. Not sure. Now let's go ahead and go inside of the customization form and see improved type safety for optional initial data properties. Let's see the suggestion here. The code assumes initial data properties exist without proper null checking. Let me see. Default suggestions. the default suggestion object always exists So that not a problem Same with the WAPI settings So if initial data exists that the widget settings schema The default suggestions object must exist. Inside, these options are empty. But the object itself exists. So that is good. Now let's go ahead and go inside of the customization view here. Let's see the issue here. error handling all right yes we could add error handling but for now i'm okay with just handling the loading state and i think there's one more comment here improve type safety for organization id oh yes this um yeah we could do that but since this is coming from clerk it's pretty safe already and we've already done it this way everywhere so i don't want to change anything I want to be consistent and this is pretty okay actually it works fine. Excellent so you can see this extension actually works super well as well and you can even use their magic AI button to fix all the issues. They also have some nitpick comments but you can see we can just improve the structure or enhance error handling, duplicated authentication logic, things we already know. Excellent yes so you can use the CodeRabbit extension as well. I didn't accept any of the changes for a simple reason. We already opened the branch. I don't want to confuse you. We're just going to fix the loading issue in the next chapter and for now we can merge this pull request so that we have history of this chapter. This is 26 widget customization and we can now go back inside of our main branch and we can resynchronize our changes here and then we can go back inside of the source control and let's double check in the graph there we go after vapi data 26 widget customization everything is good make sure that you're on the main branch you can click on this button as many times as you want and let's go ahead and mark this as completed that marks the end of this chapter and see you in the next one in this chapter we're going to learn how to load our widget customization settings from the previous chapter into our separate widget app. We're going to start by doing a very simple default greeting message change and then we're going to create public widget settings functions so that we can call them and query them within our separate widget app. And then using those widget settings we will be able to load suggestions into the widget chat box. Let's quickly recap exactly what we did in previous chapter so it's easier to understand. So I have Turbo Dev running and I'm going to go and visit localhost 3000. Inside of here, this is the last thing we developed. This widget customization screen allows me to change the greeting message, allows me to add up to three suggestions. And if I connect my voice assistant, it will also show me those settings. So just to remind you, you have to connect your Vapi account using your public and private API keys, which you can find on the Vapi dashboard. or you can visit your AWS Secrets Manager and in here you can find the private API key and just add it here and your public API key and just add it here. And once you've successfully connected, you will see your phone numbers and your AI assistance. This is our white labeling feature. And once you do that, you can see inside of here, you can modify your voice assistant settings. Now let's go ahead and let's actually display this inside of our widget chat box. so if you go to localhost 3001 which is where we are developing our widget application you will see this error organization id is required so just a quick reminder you can find a valid organization id by going into clerk organizations selecting one organization that you have and copy the organization id and after that let me go ahead and just go inside of some random source code here so i can show you how you're supposed to do this so localhost 3001 and then you will add question mark organization id and you would paste this here so that's exactly what i'm going to do here localhost 3001 organization id and there we go after that i can verify the organization and i can create my account so antonio and antonio example.com this creates a complete new session for me and this is what we're going to change now So when I click start chat, you can see that this is the default greeting message, even though I clearly modified the greeting message right here for this exact organization. So just be careful that you're using the organization ID that you have modified the general chat settings for. So be careful when you copy your ID here from clerk, make sure that you didn't copy the old one because then you're not going to have the settings. so what we have to do now is we have to go inside of our conversations.create method and we have to modify this message so that's an easy thing for us to do let me close this before you start any code make sure that you are on your main branch and make sure that the last thing you did was widget customization right here perfect now let's go ahead and go inside of our packages backend convex let's go inside of public conversations and let's find create mutation the create mutation is the one which writes the default message hello how can I help you today and as you can see we have added a to do here later modify to widget settings initial message so that's exactly what we have to do so just before we load the thread id and before we throw this error. Actually, after we throw the error, let's go ahead and let's load the widget settings. So const widget settings is going to be await context dot database query widget settings with index by organization ID, get the query. And inside of here, query equals organization ID arguments.organizationID. And let me just fix the capitalization here. And there we go. And make sure you look for the unique one. And that's how you get the widget settings for the organization where you're trying to create a new conversation for. And once we obtain those widget settings, what we can do here is widget settings??greetMessage or the default message. And you can now remove this to do. So if the widget settings exist, and if the greet message has been set, we're going to use that greet message. Otherwise, we're going to use the default one. So now let's go ahead and try and start a new chat. And here you see now it's Hulu 123, because that's exactly what I changed here. So if I just add, hi there, how can I help you? And maybe add a couple of these question marks and click save settings and then refresh here again and click start chat. That's going to be the newest greet message. So this was a very simple thing for us to do. That's why I wanted to do it first. So we get a quick win early in this chapter. Now what we have to do is we have to load the suggestions. This is a tiny bit more complicated. Let's start by creating the public widget settings functions. So inside of our convex here we are already in the public folder. Let's create a new file, widget settings.ts. Inside of this widget settings, let's go ahead and let's import values from convex. And let's import query from generated server. Let's export const get by organization ID query. Arguments are going to be organization ID, which are a type of string. After that, let's add a handler, which is going to be an asynchronous method. The handler will have the context and the arguments. Inside of here, we are simply going to fetch the organization settings, the widget settings, exactly the way we did here. So you can copy this and just paste it here. And it will work perfectly because we have the same organization ID arguments. And after that, just return widget settings. As simple as that. now that we have created this we can now use it instead of our loading state because remember every time you load a widget we have a sequence of things that we are loading we are first loading the organization we are then loading the session and then we are validating those things so what we have to do is we have to add one more step here which is after we have verified our organization let's also attempt to load this organization's settings which we can now do thanks to this public function which we have just developed. Make sure you save this file and quickly head inside of your back end here just to confirm that all functions are ready and that you have no errors. Now that we have this let's go ahead and let's add the widget atom for the widget settings. So I'm going to close everything here and let's go inside of packages my apologies inside of apps widget. Let me see. Modules widget atoms widget atoms.ds. And inside of here, let's go ahead and let's add export const widget settings atom. It's going to be an atom. And by default, it's going to be null. And now let's go ahead and give it the type that it can be. So what I want to do here to make this type safe is attempt to use the document from workspace back and generated that data model. So the same place we import the ID from. And let's try and make this widget settings like this or no. So now this widget settings, Adam should be organization ID, greet message, default suggestion. Yes, but we could omit a few things here, because the only thing we actually need is greet message, default suggestions, and the WAPI settings. So let me try and use the TypeScript omit function. If I remember how to use it correctly. And let me try and let me try to omit the organization ID. And when I hover now, okay, organization ID is still present. So I don't think I'm using this correctly. Okay, let's leave it like this for now. And I'm going to come back to this in a moment. But I think this should be okay. In the worst case scenario, we can type out the exact fields we need ourselves. Now that we have the widget settings atom let go inside of the modules widget UI screens And let go inside of widget loading screen Because this is the place where we actually load our settings In here we already have a couple of steps. So I think the last one is step two, actually. Yes, the last one is step two. So what we're going to do now is we're going to add one more query here. So far, we've only had actions, I believe we didn't have any queries, yes, actions and mutations. So let's go ahead and let's see what is the best place to add this, I think the best place would be to develop step three, right? So this is step two, let's do step three here. Step three, load widget settings. And in here, what I want to do is I want to get the widget settings by adding use query. I'm going to add API.public.widgetsettings get by organization ID. And in here, I have to use the organization ID. Let me just understand. Do we have an organization ID at this point? I think we do. Yes, we do. and I have to import use query from convex react. So organization ID, let me just check. Where am I getting organization ID from? From here. All right, so what I'm going to do then is I'm going to do the usual, if we have it, then use it, otherwise skip it. Let's do it like this. If we have organization ID, we are going to use it. Otherwise, I'm going to skip. I think this will work. and now we have the ability to load our widget settings. Once we have the ability to load our widget settings, let's go ahead and actually load them. So I'm opening a new use effect here and what I'm going to do is I'm going to first confirm if the step is not settings, return. Let me just confirm if we have the ability for a step to even be settings. It's been a while since we developed this, so I'm not quite sure. But inside of your init step, you should have settings. As you can see, that is step three here, which also means that instead of your step two, validate contact session, once we validate here, the next step should be settings. It shouldn't be done. So make sure you fix that. Go inside of step two, validate contact session. If there is no contact session ID, change the set step to settings. And inside of here, validate contact session, change the set step to settings. And instead of catch, settings as well. So step two can only go to step three. It cannot go anywhere else. And this is step three that we are developing right now. so instead of step three let's go ahead and first set the loading message here to be loading widget settings and now let's check if widget settings is not undefined which means it's no longer loading let's go ahead and do set widget settings and we don't have widgets we don't have this set widget settings because we have to add that atom here so let's quickly go up here with all of our other atoms let's just do const set widget settings use set atom widget settings atom the one we just developed so make sure to import it from modules widget atoms widget atoms and now we have set widget settings here and we can pass in the widget settings inside of here and looks like the TypeScript is completely okay with this. So I don't think we have to modify anything here. And let's just add set step. And for now, it will be done. And now let's add the dependency array here. So we need this step, we need the widget settings, we need set widget settings, and we need set loading message. Usually you don't add setters to dependency arrays, but this isn't use state. This is Yotai, so I'm not exactly sure what the rules are here. But I think that the only thing that's left is set step. And I think that is it. All right. So now what we should have is after we validate this session, we should have this widget settings atom populated with the settings that we are customizing. So let's just see. If I do a refresh here, I couldn't even see it. But I think for a second there, there probably is for a second this message loading widget settings. I'm not sure what's the best way for us to test if that is true exactly. Let's just try it out, right? So what I want to do now is I want to go inside of start chat. And if we have those widget settings, we should display the suggestions here. So the easiest way to do this is by going inside of our widget chat screen component. Right here inside of apps, widget, modules, widget, UI, screens, widget chat screen. And in here, what I'm going to do is I'm going to get my atom value for the widget settings. So const widget settings will be useAtomValue, widget settingsAtom, the new one which we just created. Make sure to import it here. And once you have the widget settings, let's go ahead and let's create a memo variable. So const suggestions here, useMemo, which you can import from React. Make sure you do that. And in here, let's just prepare an empty useMemo. and let's do if there is no widget settings meaning that the user didn't change any settings at all we have nothing in the database we just return an empty array no suggestions to be made otherwise we have to modify what is now an object into an array because remember inside of our schema the default suggestions are an object right so we have to transform this into an array so let's go ahead and do the following return object dot keys widget settings dot default suggestions dot map get the individual key here and then inside of here let's return widget settings dot default suggestions open square brackets as in we are trying to access this specific index inside of this array key as key of type of widget settings dot default suggestions just like that and inside of the dependency array add the widget settings now that you have these suggestions you should be able to render them so let's copy the suggestions here and let's find a place to add this too i would do it after we close the ai conversation right here to do add suggestions so right below this let's just do json.stringify suggestions null two and if we have them there we go suggestion one suggestion two and then this this is exactly what I have added here, suggestion one, suggestion two, and then blah, blah, blah, right? So exactly what I added is what's written here. So now what we have to do is we have to, well, just one thing, in case this is not true for you, try just rendering the widget settings. And then in here, you will see the entire widget setting. So if this is null for you, it means something is happening here inside of your widget loading here. Make sure you didn't pass the invalid organization ID here or something like that. Make sure that your get by organization ID query is correct. Make sure that you have .unique, that you are returning this, those things, right? But this is fairly simple. It should work. I don't think there are any rooms for mistakes here because it's pretty straightforward. But yes, just try to retrace your steps here in case you just can't figure it out why it's not working. And also try going inside of the convex dashboard and try finding the widget settings there. Maybe they weren't created in the first place. But if you're able to see your widget settings right here, right, if they have a certain value, that means it works. You develop this in the previous chapter, right? Another thing that could be issue is you have an invalid organization ID. So double, triple, quadruple check that if it's not working for you. And once you've done all of that you should be seeing your widget settings here inside of your atom because we add that to the atom in the widget loading screen using the use query where we check if we have the organization id and we do that inside of the use effect right here perfect now i'm just thinking about one thing here. Maybe I should also check if I have the organization ID here. Even though technically I don't think this can ever go wrong because of this check right here. Just in case maybe I should also have this. Let me check and refresh if this is messing anything up. it's not yes i would recommend maybe having this simply because this can be i'm not sure what's the initial state of the widget settings is it null or is it undefined perhaps undefined is only while it is loading right so i think this should all be okay you can also remove it i don't know i think it works okay right now now that we have these suggestions which is the one we actually care about let's actually render them so the cool thing about this is you already have all the components needed for this instead of your packages ui source components you have the ai folder and we added suggestion.dsx we haven't used it up until this point but this is where they will come in handy so let's go back inside of the widget chat screen and let's go ahead and let's import our ai suggestions. So I'm going to go here to the top and I'm going to import AI suggestion and\nand AI suggestions from workspace UI components AI suggestion. And now it's time to use them. So let's scroll back down to where we JSON stringify our suggestions. And let's add AI suggestions here. Let's give it a class name, flex, full with flex call, items end, and padding to. and inside of here let's do suggestions.map get the individual suggestion if there is no suggestion let's go ahead and just return now so in case it's something invalid let's not render that otherwise let's return an ai suggestion component which is a self-closing tag and let's pass in the key to be suggestion let's pass in the on click here for now to be an empty arrow function and suggestion will be suggestion like this just a single suggestion suggestion there we go let me just see what the error is so duplicate identifier suggestions oh it looks like i already had this at some point but i forgot to use it so okay we already had it no worries now let's just go ahead and check it out and there we go suggestion one suggestion two and suggestion trait you can see them rendered right here the problem is when i click on them nothing happens now let's develop that uh something to happen so on click form set value message suggestion should validate true should dirty true should touch true and then form handle submit, passing the on submit method and auto execute. Just like that. And now when you go ahead and click on any of these, it will automatically submit. And you can see, of course, this is completely unclear for the AI. It has no idea what this is. But now you have the power to suggest to your users whatever you want them to ask, what is your pricing plan? Or maybe how are you? Or are you a real person? Whatever you want to be here, just save the settings. Let's refresh again. And there we go. Now we can ask, what is your pricing plan? And I already forgot if we had this in our knowledge base or not. but basically the best thing to add here would be to add it to give it questions that you have answers for in your knowledge base right so i think that i have removed them because we tested removing embeddings but go ahead and add any txt file like frequently asked questions which you can find in echo assets let me just go ahead here so instead of echo assets here knowledge base I added a bunch of examples so just choose one text files are super easy to work with and you can open them yourselves and see if they are correct or not so just add one of those and then add frequently asked questions to be one of relating to those now there is one problem so these suggestions take up a lot of space at some point you can see how this starts to become a little bit weird so what I would suggest is only displaying suggestions if you have a certain number of messages. So in here, what I'm going to do is I'm going to open two UI messages, messages.results or an empty array, question mark.length is equal to one. So if only the greeting message has been sent, only then render the AI suggestions. You're going to see how this looks now. So you can see now there are no more suggestions. But if you are just at the start of the conversation here, you can ask any suggestion here. Keep in mind that this will be a much smaller screen, right? It's going to look something like this, and it's going to be even shorter in height. So that's why you kind of need to limit when to show these suggestions. You can also, you know, change this to be instead of beneath one another, next to each other, and then allow the user to scroll if you want to. But I think this is a more recognizable way of suggestions. So yes, That's why I added this. When you are on the first message, you can ask for a suggestion. But after that, suggestions disappear. I think this is also kind of the usual way of how suggestions work. This is exactly what I wanted us to do in this chapter. So we can now remove to do add suggestions from here. I think that's all we actually needed here. I am a little bit skeptical about this part here. widget settings undefined simply because I'm not sure what's the initial state of the widget settings. I'm hoping it's null. Yeah, I don't know. I'll have to research a little bit in my next chapter just to give you the correct information about this. What I'm worried is that this doesn't accidentally skip the widget settings if they haven't loaded yet, if they haven't even started loading for some reason we'll see but i've tried five times in a row now it works perfectly so i'm pretty sure it's okay but do keep an eye on this now let's go ahead and see if that's all we wanted to do we created public widget settings functions and we loaded suggestions into our widget exactly what we wanted to do let's open a pull request and let's merge it so 27 widget config let's go ahead here let's stage all of these changes here 27 widget config let's go ahead and click commit i'm going to open a new branch 27 widget config and i'm going to publish this branch then i'm going to go ahead inside of the pull request here and i'm going to open uh well a pull request And here we are. So 27 widget config. Let's create a pull request and let's review the changes. And here we have the summary by CodeRabbit. The chat now shows clickable AI suggestions to quickly start a conversation, selecting one autofills and sends the message. Assistant greeting is now personalized based on your organization's widget settings with a sensible fallback. Widget loading flow now includes a settings step that fetches and applies your organization's configuration before completing. Widgets supports organization scoped settings including default suggestions applied automatically during initialization. So exactly what we developed. Now in here let's take a look at some of the comments. So handle potential duplicate widget settings per organization. So yes, convex secondary indexes aren't unique constraints. So using unique here will throw an error if more than one record exists for a given organization ID. That is 100% true. Convex secondary indexes aren't unique constraints. So as said here, we have two options. We can either enforce exactly one widget settings per organization at write time. So let's take a look at our code here. So instead of our widget settings in the private here, when we do absurd, you can see just by the name, we do absurd, right? So what we check if, is there an existing widget settings? If it is, we simply patch. Otherwise, we create it for the first time. So this is exactly what we are doing. We are enforcing exactly one widget settings per organization. So that's how we fix this issue. In here, they recommend switching to a more tolerant first instead of unique, but I'd rather we have an error, right? Because then we know, okay, this enforce here is obviously not working as it should. We need to fix our code. Whereas if you use first, it's not going to throw an error. It's simply going to select the first one, but the first one should be the only one or the unique one. So that's why I don't think we have to do anything here because we're doing exactly what they recommend here using the absurd method. And they have the same comment here for our new public widget settings. That means that this pull request can be merged. So let's go ahead and merge this pull request. And once you've merged it, let's go ahead and switch to our main branch here. So clicking down here, selecting main, and then clicking right here to synchronize the changes. Once you have synchronized the changes, I always recommend going inside of your source control here, clicking on the graph, and just confirming 27 is the last thing you merged. And just one thing to ease your mind, instead of our widget loading screen, I told you I'm not too confident about this, but now I am, because I went to convex documentation here, and I found UI patterns. Check for undefined to determine if a query is loading. So what we do here is we go to the next step only if widget settings is not loading. Not if it doesn't exist. It's completely fine for widget settings to not exist. But I don't want to go to the next step until I've at least tried or attempted to load the widget settings. Completely fine. And if we get back null, that's fine. I just want to make sure that even users who, even organizations who don't have widget settings can still get past this step because widget settings are not required. And looks like that is exactly what's happening. The use query React hook will return undefined when it is first mounted, before the query has been loaded from convex. Once a query has loaded, it will never be undefined again, even as the data reactively updates. I just clicked somewhere but then it goes and says undefined is not a valid return type for queries right so you can use this as a signal for when to render loading indicators and placeholder UI so that's why this is completely safe I will double check it just in case with a completely new organization which doesn't have widget settings and I recommend you do that as well but I'm 99.9 sure this works just fine. Perfect. I believe that marks the end of this chapter. So let's just mark this this this and this as completed Amazing amazing job And see you in the next chapter In this chapter we going to add Vapi functionality to our widget application So in the chapter six, we actually started developing this. But there was one problem. If you go back all the way to chapter six, I'm going to help you and remind you here. So what we did is we developed our first AI voice assistant. We did this by creating a Vapi account and we followed their instructions to set up a customer support agent. We added some files and tools to their knowledge base and well to their dashboard. We then tested that agent from their dashboard and then we tested it again from the client SDK. But there was one issue with this. The issue was we had to use our assistant ID and we had to use our API keys. And this is where I explained white labeling for the first time. At this point, we didn't really have any of that. But now we do. Each of our organizations now have their own API keys, allowing them to have their own knowledge base, phone numbers, assistance, and tools. So now we can finally go back in this chapter 28 and we can combine all of that and enable each organization to display their own assistant and their own phone number. So let's start by creating a public secrets function, which will allow us to decrypt AWS secrets for a specific organization. And don't worry, we're not going to allow anyone to decrypt the private API key. only the public API key since this is a public function meaning it will be used in the UI. So it's a public key for a reason it is to be stored in such a way that can be retrieved from some API so we're not breaking any security rules here we're not going to leak the private API key onto the front-end network so don't worry about that. Let's start by building the public secrets functions. So inside of our packages backend, let's go inside of public here and let's create secrets.ts. Let's go ahead and let's import values from convex values. Let's import internal from generated API. Let's import action from generated server. Let's import generate, my apologies, get secret value from libsecrets and parse secret string. So we again need to access those utils that we created the first time we added AWS Secrets Manager from our secrets lib. Now let's export const. Get Vapi Secrets action. So it needs to be an action because it's going to access a fetch to a third-party API. Let me just fix the typo. action. The arguments that we're going to accept is organization ID, which is a type of string, and a handler, which is going to be an asynchronous method. The handler, as always, will have access to the context and to our arguments. Now we have one problem, and that problem is actions cannot directly query the database. If you try and get our plugin by doing await context.database.plugins, it does not work. So what are plugins? If you remember, we have the plugins table in our schema. And it's basically what allows us to store the secret name, the type of the service and the organization ID that it belongs to. So inside of your VAPI plugin, whenever you create a connection, what you actually do is you create a plugins schema in the database. And inside of the secret name here, we store, well, the secret name from AWS Secrets Manager. So we never actually store the API keys directly in our database. We just store a reference to decrypt them later when we need them. So now we need to access these plugins so we can obtain the secret name. The problem is we don't know how to do that. Well, luckily for us, we can use run query and in here we can use our internal system. So internal.system.plugins.getBy organization ID and service. Looks like we already have this because we used it somewhere else. So we pass in organization ID to be arguments organization ID and the service that we are retrieving for this organization will be vapi. If there is no such plugin let's immediately return no there's nothing we can fetch here so let's just go ahead and quickly revisit this so inside of my convex system in here i have plugins.ts and i have get by organization id and service internal query and in here we're using the index to quickly fetch a plugin and get the secret name for that service and for that organization id so if i search through my code you can see that I already used this a couple of times, right? And now we just added this into this new secrets public function. Now let's go ahead and continue. Now that we have our plugin for this organization ID and for Vapi, we can get the secret name. And we can do that by accessing plugin.secret name. And then we can get the secret by doing await get secret value secret name. This will use our AWS API keys to decrypt this secret. Basically, this one right here. We're going to try to decrypt this. And once we have decrypted that, we can finally get this secret data. Now, the secret data will very simply use the parse secret string and pass in the secret from above. The problem is right now it's a type of any object. We can make this a little bit better by actually giving it the type of what it will return. How do we know what it will return? Well, we can simply look at the retrieved value here. So we have private API key and public API key. So for now, we are going to map both of them here because we will decrypt both of them for now, but we are not going to return the private one. We are only going to return the public one because the private one should only be used within our API functions. It should never be returned to the front end, right? Even if it's technically safely encrypted, it's not exactly laid out, explained in front of you, which still should not leak it into the front end network because someone with a high skill set can grab that and that would be, well, a bad thing to happen. So if there is no secret data at all, let's return null. If there is no secret.public key, my apologies, secret data public key. Let's return null as well. Let me just see if I did something incorrectly. So it's public API key. My apologies. Return null. Let's duplicate this. Private API key return null. And what we are actually going to return is just the public key. So secret data, public API key. And let's actually also call this public API key so we are consistent everywhere. I just want to double check that we are consistent. So let me search this. Yes, it's called public API key everywhere and private API key everywhere. Perfect. So there is no instance of private key nor public key. Only public API key and private API key. Exactly. We are consistent everywhere in our code. Now that we have this, let's go ahead and see what else we have to do here. Let me quickly check. So we just created the public secrets functions. Now we have to load those VAPI secrets using our loading screen. The same way we just did the widget settings. In order to do that, we need to prepare the atoms. So let's go inside of apps, widget, modules, widget, atoms, widget, atoms. In here, let's go ahead and export const, VAPI secrets, atom. Let's make it an atom by default. let me just do this properly so it's going to be null by default but the actual type of this will be a type of public API key which is a type of string or it's going to be null like this and I'm trying to think if there's a way we can infer the return type of our newly created function but I think this is okay. But yes, if in the future you ever change your public secrets to return something else here, you should also be mindful and change it in the VapiSecretsAtom. That's why I always try to use the infer method here. As you can see, I never really type manually what we are going to store here. But for this specific case, I think this is okay. So I'm going to leave it like this. make sure that you write public API key inside of an object like this. Make sure you didn't misspell anything the same way we're doing it here. Once we have this prepared, it is time for us to go back to our widget loading screen. So let's go inside a widget loading screen located inside of the modules widget UI screens widget loading screen. And let's quickly check our steps here. Organization, session settings and then we have a VAPI. So let's go ahead and prepare that. Here it is step three load widget settings. Let's go ahead and just go here. Step four load VAPI secrets just like that. So let's define a constant get the VAPI secrets to be use action which we already have here and let use API public secrets get VAPI secrets in case you are getting type errors here as always double check that you have turbo dev running and that your workspace back in here the last thing you should see should be convex functions are ready That way you won't have any problems here. Great. Now let's add a new use effect here. The first things first, let's check if we are on the correct step. So if this step is not VAPI, let's break this immediately the same way we did in all other steps here. But if it is VAPI, let's go ahead and change the loading message here to be loading voice features. Or you can write loading API keys, whatever you prefer. And let's now call get VAPI secrets here and pass in the organization ID. though this should only be if we have organization ID I think we can add that here and this is still showing me an error let me try and see maybe if there is no organization ID return yeah I think I use the invalid one here let me see no okay not sure what i'm doing wrong but looks like this is working too basically in order to even attempt to load this we need organization id and this is safe to put here because if there's no organization id nothing else will work anyway so i'm okay with blocking this entire flow if it happens that we are missing an organization ID. All right, now that we have this, let's go ahead and close this and let's do dot then. And then in here we have the secrets. And now we have to reuse that atom, which we've just created here. so let's do const set vapi secrets use set atom vapi secrets atom make sure you have imported the vapi secrets atom now we have set vapi secrets and then in here in the then we can do set vapi secrets and pass the secrets and you can see that this is a type of secrets public api key and that's the exact thing that this accepts. That's why you need to be careful with the types. And in case an error happens, we're going to set Vapi secrets to be no. And well, that's okay. This can fail because this is optional, right? This isn't required for the chat box to load at all. So yeah, I think this should all be okay. the next step here will be done and the next step here whoops will be done as well just like that now let's add all the dependencies here so step organization id get vapi secrets set vapi secrets set loading message and set step i think those are all the ones we've used and in fact if there is no organization id let me just see here i still don't like how i did this yes i should find a way to kind of assert that the organization id will always exist let me just find so we do that here set they're going to when once we validate the organization yes so at all of these other hooks which we almost have a 100 chance of organization id existing yeah so i don't like that we have to do this again but i would rather we do set screen error set error message let me just check so the same thing that we are doing here let me just check this one basically this yes this is the exact thing i want us to do even though i i can't imagine how this can happen really but yes let's go ahead and do this organization id is required set screen to error and return great uh and now what we have to do is we have to go back to step three load a widget settings and once we finish loading the settings whatever the result is null or the settings object we actually set the next step to be vapi so that is quite important right make sure that nowhere inside of this use effect for the load widget settings do you set step to done you should only set step to vapi so this use effect gets loaded and then we attempt to get vapi secrets and regardless if this succeeds or if it fails we set step to done because this is not required. It's just an extra to make our chat box even cooler. And at this point, if you try the chat box widget here, maybe you will see, yes, you can see it for a second, loading voice features. At least I can see it. So after we do that, it means it successfully loaded the voice features, meaning it loading the API keys. And in combination with our widget settings, we can now change the selection screen. So let's go ahead back inside of the selection screen. So widget selection screen right here. And now we're going to add some more buttons besides the start chat button, but only if specific things are loaded here. So let me go ahead and add const widget settings, use atom value, widget settings atom. Make sure that you add the import for the widget settings atom like this. And now let's also just do one more thing. Let's go inside of our widget atoms. And just to make things easier for us, let's do export const has Vapi secrets atom. atom get get vapi secrets atom and just check if it's not null. So do we have the secrets or do we not? Now this way in the widget selection screen what we can do is we can use that new atom here. So let me just add has vapi secrets use atom value has vapi secrets atom. The same one we just created make sure you import it from the widget atoms now that you have the widget settings and the has wapi secrets you can now safely decide what to show to the user so let's go down here right now the only thing we do is we display this button so let me go ahead and copy this button and paste it below and you will see that now we have two instances of start chat so let's go ahead and change this one to be start voice call and this would be microphone icon from lucid react mic icon like that and you can see how that's going to look like but we should only display this if we have vapi secrets and if widget settings question mark vapi settings question mark assistant id exists So regardless, if the user has connected their voice assistant, if they haven't changed their widget customization settings, we have no idea what assistant they want to use, right? So you can see that since I have added an assistant, I have this. But if I go inside of my widget customization here, and if I change this to none and save settings now, you can see that immediately when I refresh here, at least what should happen is this should be empty exactly. And when I refresh here, there we go. That button disappears. So even if you have connected your VAPI integration, only when you go to the widget customization and actually select an assistant and select a phone number and save those settings, will you see on the next refresh those new features. So now let's go ahead and do the exact same thing here. but for the phone number like this. And this will be call us. And it's going to be using the phone icon. You can import this from Lucid React as well. And there we go. So if you try and remove the phone number, this button will hide itself as well. Now what we have to do is add the two new additional screens that will appear when we click on these buttons. The first one is going to be the voice screen. So let's quickly go back inside of our views folder in the modules widget and let's go inside of the widget view. And in here we have voice to do voice. Great, so that's already set. Make sure you have the voice instead of your screen components here. And now in the widget selection screen, you can modify this to show voice. So I'm gonna go ahead and let me just find. So in here, they say handle new conversation, but I'm going to make this simpler. Do I have set screen here? I think I do. And I will just choose a voice. So make sure you have set screen in the widget selection screen. You should definitely have it. It's basically the use set atom using the screen atom. And now when you click start voice call, there we go to do voice. So you have to refresh to restart this. And the same thing should happen if you go here. and let's do what is it set screen it is contact yes i think this is the one and then you click here to do contact great so now we have to develop a boat of this we're going to start with the more complicated one so we complete this fun feature that's going to be the voice call the good thing is we already have the hook ready to do this. We just don't have the, we just didn't, this is the hook I'm talking about, use VAPI, right? So you already have this, not use VAPI data, use VAPI in the apps widget We developed this in chapter six if you remember And in here we used our own API keys as well as our own assistant ID So we going to have to modify this useVapi hook and we going to have to use the atoms which we now have from the widget settings and from the Vapi secrets. And that will allow us to speed the process up. So let's start by developing the widget voice screen. So I'm just going to close these things because I have a lot of them open. Instead of the widget modules, UI screens, I'm going to copy the widget chat screen. I'm going to paste it. And I'm going to rename this to widget voice screen like this. And I'm going to simplify it a lot, but it does have some elements that we need. So, okay, you know what? I think this might be a bad idea because voice screen is significantly easier. So what you can just do is just make the widget voice screen completely empty. And let's go ahead and just import line by line what we need. Arrow left icon, microphone icon, and microphone off icon. We then need the button. And then we need the AI conversation, conversation content, and conversation scroll button from components AI conversation. And then we also need the message, AI message, and AI message content. And then we need our useWAPI method from modules, widget, hooks, useWAPI. So this is the one I was just showing you, right? The one we developed quite early on. And one thing we also need here is the widget header. So let's export const widget voice screen like that. Let's just prepare set screen here to be use set atom from Yotai and screen atom from atoms. So that's one more thing that we need. and then we can prepare use wapi here we're not going to do anything now let's go ahead and let's return a fragment here widget header inside of here let's add a div with a class name flex items center gap x of two and inside of here i think we can just copy something let's see from our inbox screen yes it's the exact same thing here so you can copy everything inside of the widget header actually and just put it here and this will bring the screen back to selection and instead of inbox this will be voice chat like that and then let's go ahead and do the following outside of the widget header not a paragraph my apologies add a div here and give it a class name flex height full flex column items center justify center gap y of 4. Another div inside with a class name flex items center justify center rounded full border background white padding 3 and render a microphone icon inside. We already have this from Lucid React. Give the microphone icon a size of 6 and text muted foreground. Then add a paragraph. Transcript will appear here. And let's give this a class name of text muted foreground. now that we have this uh i think um i think this is enough for us to go back inside of the widget view and replace the voice with the widget voice screen which we've just created right so screens widget voice screen we still have some unused components we are going to add that and there we go we have something that says transcript will appear here the only thing I think it's missing here let me just check how do we okay so we also need to add the widget footer I forgot that so here at the bottom let's add widget footer and let's import that from components widget footer okay the thing that seems weird here is it's not taking full height for some reason. So let me just debug a little bit. All right, so I found out that we can make this full height by also adding flex one like this to this div and then it will take the entire screen. Now one thing that I also want to add here just above the widget footer here is another div with a class name border top bg background padding four and inside of here a div with a class name flex flex column items center gap y 4 and then in here we're going to add a div with a class name flex item center gap x of 2 and then let's add a div which will be a self-closing div so it's going to be like a little dot we're going to style it now so it looks like a dot size 3 rounded full and let's give it animate pulse and background red 500 like this so like a little dot at the bottom and besides that dot a span which will say assistant speaking like this and give this a class name text muted foreground and text small like this assistant speaking and now let's actually go ahead instead of this use of api and let me check let's try and get is connected is speaking transcript start call and call and is connecting great so we have all of those inside of use VAPI. IsConnected, IsConnecting, IsSpeaking, and the transcript. And we return all of them. Perfect. So we can actually enhance this already and make it make a little bit more sense here. So this is what we're going to do. First, let's change the text. If IsSpeaking, then change the text to AssistantSpeaking. otherwise listening. So we indicate whether it is our turn to talk or is it the assistant's turn to talk. And then let's go ahead and wrap this class name instead of CNutil. Let's import CN from workspace UI lib utils. So this will be the default classes like this. let's go ahead and remove background red and animate pulse and let's do if is speaking then do animate pulse bg red 500 otherwise simply do bg green 500 so for now it's going to be listening right but this entire thing will only be displayed if is connected so if we are not connected we're not going to display this at all like this so right now this is just empty so instead what we're going to do is just below this let's add a new div here with a class name flex full with justify center is connected question mark we're going to do a button which will say end call otherwise we're going to do another button which will say start call there we go so start call right now and now let's go ahead style this so the class name will be full with disabled will be if is connecting size will be large on click will be for now an empty arrow function there we go again keep in mind this will be a very small like this and let's for now just change this to true so that we see the end call button and now let's do the same thing for end call. So class name here will be full width, size will be large, variant will be destructive. And on click here will be just an empty arrow function. There we go. So now we have end call. Change this back to is connected. And you should have start call. Now let's add some icons here. So this will be microphone icon. And in the end call, add mic off icon. There we go. Perfect. And we actually don't need the widget footer. You can remove it. And you can remove the import for the widget footer as well. So just like this, transcript will appear here and the button to start the call. The problem is if we attempt to start the call now, it's actually not going to work. So we have the start call button, right? So we can use it actually. Let's find this start call. Let's just do start call here. The problem is we're going to get an error. As you can see, assistant or squad workflow must be provided. That's because currently, instead of our use WAPI, we have empty start and we have empty API keys. so if you want to you can use your own api keys just like we did in chapter six uh why would you do this well if you had some trouble with aws yeah feel free to just you know for fun add your own api keys and your own assistant id here just so you can follow the tutorial along right but for those of you who are able to set up the aws secrets thing we're going to have to go inside of use WAPI and we're going to have to actually load those API keys. Just before we do that, let me add the end call method here. So in here, end call like this. Now let's go inside of the use WAPI and let's add the atom.\nhere. So I'm going to do const of API secrets, use atom value from Yotai and pass in the API secrets atom. There we go. So use atom value and API secrets atom. Besides that, widget settings. Use atom value, widget settings atom from the same place. And now that we have both of them. Let's go inside of the use effect here. And first things first, if there is no VAPI secrets, let's immediately return. There's nothing we can do here. We can now remove this comment and we can now initialize the new VAPI with VAPI secrets.public API key. And you have officially learned white labeling from start to finish. Now each organization can bring their own API keys and see it in their chat box. Amazing job. But still, we have to actually make this work. So let's quickly go down here where we use the vapi start. And in here, we're going to do a similar thing. So set is connecting to true, sure. But then, actually, no, don't even do it. So before, just check. If there is no vapi secrets, simply return. or if there is no widget settings.vapi settings.assistantid. There is nothing we can do that. And then you can finally just use widget settings.vapi settings.assistantid. Perfect. And now you can remove this comment too. And let's go inside of the widget voice screen here. and let me just find a nice place to render the transcript. So we can do that just below the widget header here. Let's just do json.stringify transcript null and to. The transcript is extracted from the useVapi hook. So I'm going to refresh. I'm going to go inside of start voice call and I'm going to attempt to do this right now. You can see this is an empty array. So I'm going to pause the video and you will either see the transcript or maybe an error. And what we got was a combination of both the transcript and an error. So I'm not sure if you got this error. Ignoring settings for browser or platform unsupported input processors audio. I'm not too sure what this is, but it looks like it's working even with that. So I'm just going to Google this for a second just to see if we're doing something incorrectly. Maybe it's because whenever you start a voice call, you actually have to interact with the website. Let me see. Can I get that error again? So I'm starting call now. Same thing. I'm getting the error. And now the assistant is actually speaking. And you can see things here. It's kind of working, but I'm not sure what this error is. So let me just quickly research this. All right. So I've searched for that exact issue. And apparently it could be just a web browser thing. I'm not 100% sure. And I'm not even sure if you will experience this. It could be the microphone that was selected for my browser here. Maybe something is wrong with that. I'm not too sure, but I can continue building forward. So it's not really a problem for me. but all I know is that I did not have this error when I first started when I first built this so I'm not exactly sure why it's happening right now I will definitely try to give you some more answers in the next chapters but I hope that you are in the same situation as me at least so that you can at least see the transcript here because there is one more thing we have left to do here and that is to display the transcript in a nice way. So for now, I'm just going to ignore this because this doesn't seem like a breaking error. It just seems like a warning. Like it's ignoring the settings for browser, unsupported input processors. So that is definitely talking about my microphone. So maybe this is only for me. Maybe you don't even have this. I have no idea. That actually does make sense because I'm now speaking in my recording microphone. But when I developed, I was using my MacBook microphone. So maybe it's about that. I'm not sure. I just hope that you can see the transcript. I'm going to ignore this error for now. And I will give you some answers in the future chapters if possible. For now, let's go ahead and let's make this transcript appear in a nice way. So let's go back and serve the widget voice screen. And now what we're going to do is just after the widget header, let's go ahead and do the following. if the transcript.length is larger than zero in that case we're going to display the transcript otherwise we're going to display this transcript will appear here div so let me just quickly add ai conversation here and let's go ahead and give it a class name height full flex one and inside AI conversation content, transcript.map. Let's go ahead and let's do message and index, AI message, AI message content, message.text. and let's give the AI message from to be message.roll and key, let's just make it message.roll. Let's do a combination of things. Let me just try and do this like that. Dash, let's do an index. dash message dot text i think this should be somewhat unique uh great and let's also add ai conversation scroll button which is a self-closing tag and now you should have your messages displayed like this so i'm going to start the call again and i'm going to test it out and here we go so you can see that my bubbles are displayed in blue their bubbles are displayed in white so same like our chat interface here the only thing I don't like is how it scrolls so it should only scroll within this area so let me see can I somehow fix this overflow y auto maybe here I'm not sure I think it's mostly about this flex one thingy not sure I will have to kind of debug this because I don't like how it's scrolling right now let me try scroll no no it's not fixing right now anyway yeah let's leave it at flex one for now perfect but this is where I want to leave this chapter now. We do still have this call us, but that's super simple. We can just do that in the next chapter. It's just some UI and the display of the phone number. But in between those two chapters, I want to kind of give you some answers about this, even though I'm fairly certain this is the microphone that I'm using. And I also want to find a way to fix that scrolling issue so it actually displays in a nice way. And then we are going to wrap it up with this. But so far, amazing, amazing job. So let's just quickly see in here. So we added the WAPI secrets in the loading screen. We modified use WAPI hook to use organization secret and we modified the selection screen UI to display a voice option. So we did this, we did this and we developed the voice chat. So exactly what we actually had envisioned here. In the next chapter, we're going to do the call us or contact us screen, which is far more simpler than this. Thankfully, we were able to reuse our use VAPI hook from before just by plugging in the new VAPI secrets and the new assistant ID from here. So now the user from their dashboard can choose exactly what assistant they want to use and what phone number. And this is basically such an insane improvement over just us wrapping the Vapi API and trying to replicate all of their features. Because now, you know, our users can just head to Vapi, they can go inside of their dashboard, and in here, you know, they can just set up whatever they want and just load that assistant here and load that number here. and that will be a much better experience than us trying to replicate VAPI, right? Perfect. So now let's go ahead and merge this. So 28VidgetAPI. Let me go ahead and close the graph. I'm going to stage all of the changes. 28VidgetVAPI. Let's go ahead and commit. I'm going to open a new branch, 28VidgetVAPI. and I'm going to publish this branch. And now let's open the pull request. So here we go. Compare and pull request 28, a widget VAPI. That's correct. And now let's review our changes. And here we have the summary. We added voice chat to the widget. We can start and end calls. We get the connection status as well as live transcript display. We introduced a dedicated voice screen with controls and real-time conversation view. The selection screen now shows a voice call and call us options, but only when voice features are available. The enhanced loading flow to fetch voice feature access and proceed accordingly with graceful error handling. Amazing, amazing, amazing job. Let's go ahead and merge this pull request here. and now let's go ahead and see if that's all we had to do that's right we just had to merge the pull request and as always whenever you merge head back inside of your IDE go back instead of the main branch and click synchronize changes just to make sure that your main branch is now up to date with your github repository and check the graph here to confirm that the latest thing you just merged is 28 widget vapi i believe that marks the end of this chapter amazing job and see you in the next one. In this chapter, we going to add some improvements to our widget We going to start by explaining the error from the previous chapter So basically what happened is there was an error every time I clicked start call from my VAPI voice call screen. I've come to a conclusion that this is a browser issue. It could be due to us developing on localhost, it could be due to lack of permissions, or it could be due to the microphone. but the important thing is even though it's thrown as an error it is more of a warning because it doesn't actually break the functionality it will simply fall back to another microphone that it can access and here's another thing that I found out so I tried into another browser in this case Safari and let's see what happens here when I click start a voice call and click start call after I allow the microphone permissions you will see that we actually get no errors in Safari there we go so the assistant is now speaking and there we go we have their transcript right here so this is isolated to a browser bug so we are okay with leaving it as it is but now we have a scrolling issue in the widget so make sure you have your app running here make sure that you're on your main branch make sure you have synchronized all of your changes right the last thing we merged was 28 widget vapi and now we're doing 29 widget improvements so what is the scrolling issue in the widget so if you go ahead inside of your widget here with the working organization id i purposely entered this responsive mode and you can see that when i try to collapse this this is how scrolling happens that's not what i want i want only this area right here to scroll and this to kind of be fixed as well as this. And while this isn't really an issue here, like this is fairly okay, it becomes an issue when you have these conversations, right? You can see that you can't find your chat button. You have to go all the way down to find it, right? So that's not really nice. And same thing in the voice call, right? When the messages appear here, you can't really see them unless you scroll down. So, and also auto-scroll doesn't work, right? So the AI components should actually auto-scroll on new messages. It should scroll down to here, but none of that happens. So let's go ahead and fix that. So what I want to do is I want to go inside of my apps, widget, my modules, widget, UI, and let's go inside of views, widget view. And first thing I want to do is remove this to do, where we ask confirm whether or not this is needed. so let's remove those two and just leave flex height full width full flex call overflow hidden rounded extra large border and background muted and then go inside of your layout in the widget app and in here where you wrap the children after the providers go ahead and add a div like so and give it a class name with screen and hide screen. So now not much change here, but you should see that now it scrolls properly exactly as we expected. So this kind of stays fixed. This stays fixed. This is the only scrollable element. Same thing happens in our conversations, right? And in here you can see we now scroll to bottom and we can scroll our messages whilst maintaining our text input right here. So that is what I wanted us to achieve. And if you try the transcript voice chat, it should also work. But let's just go ahead and just improve a few things here. So we can now go inside of our modules, widget UI, screens. Let's go inside of the widget voice screen. And in here, we can now remove flex one. so we just need height full and now let's also go inside of the widget chat screen let's just confirm everything's okay here so ai conversation i think this is fine as it is we don't need to change anything here and let's also go inside a widget inbox screen so i'm just comparing with my original source code just confirm that there isn't anything that we're adding that we don't need. I think all of this is fairly okay. Excellent. Now that we have that solved, let's go ahead and mark that as completed. And now let's add the contact screen. So in the previous chapter, we only developed the settings to load the assistant because that was the harder one. So what we have left here is the contact screen, which is currently in to-do state. So I'm going to go ahead and I'm going to copy the voice screen. I'm going to paste it and I'm going to rename this to widget contact screen.tsx. So widget contact screen. And now in here, we're going to simply display the phone number that is loaded, right? Nothing else we really need to do here. So we can remove the hook from here. And instead, what we can do is we can add widget settings, use atom value from Yotai and pass in the widget settings atom. Make sure that you import it like that. Then let's go ahead and let's get the phone number here to be widget settings question mark dot vapi settings question mark phone number. Let's develop the handle copy method here and let's prepare copied and set copied use state and by default let's make it false and let's import use state from react. Instead of this handle copy, let's press check if we have the phone number at all. And if we don't, let's break the method early on. Now let's go ahead and await, which means this needs to be asynchronous. So await navigator.clipboard.writeText phone number. Like that. Let's go ahead and put this inside of try and catch. and in here let's console error the error we're not going to throw any toast messages here simply because this is in a very small screen so I'm not sure how that actually looks and in here we're just going to do actually let's do let me just see in the finally here I'm trying to find the best way to do this actually no it doesn't need to be in here let's do set timeout set copied to false after two seconds and in here set copied to true like that perfect now let's go ahead and let's did we rename this we didn't so widget contact screen let's rename that and in the widget header let's change this from voice chat to contact us like that in here we won't need the transcript at all we can remove that we won't need this we can remove a lot of things after the widget header and we'll just build it again it's easier this way. There we go. So just leave the widget header. Not much we need. You can remove all AI imports here and you can remove the icons. We can add them back if we need them. Same for CN, same for use VAPI. So if we need them again, we'll just import them again. Below the widget header here, let's go ahead and let's add a div, the class name, which is going to be flex height full flex column items center justify center and get y of four in here let's add a new div with a class name flex items center justify center rounded full border, background white and padding three. In here let's add a phone icon from Lucid React with a class name size six text muted foreground. Below that let's add a paragraph available 24 7 and let's add a class name here which will be text muted foreground and then inside of here just below we are going to render the phone number and let's change this to be font bold and text to excel now what's what i want to do is i want to render that here so let's go inside of our widget view component and let's remove the to do with widget contact screen which is a self closing tag make sure you've imported it and there we go you should see the number here so if you have any ideas for a better design here feel free to add it of course right I'm just developing the one I thought of now after this div here let's open a new one with a class name border top bg background padding 4 let's add a new div here with a class name flex flex column items center gap y of 2 inside of here let's add a button and in here we're going to check if we have copied this we're going to render a fragment which shows the check icon from lucid react give it a class name mr2 and size of 4 and text copied and if not we're going to do a copy icon inside of a fragment with copy number text like this and let's import copy icon from lucid react so just make sure you've added check icon and copy icon both from lucid react just like that and we're going to add one more button here which will very simply be a link make sure to import that from next link not from lucid react so make sure double check that your import is working and give this an href of tell phone number so if they are on their mobile devices or if they have facetime on their macbooks when they click on this button it will actually call the number so that's quite cool i think and let's add phone icon here again and call now button let's go ahead and give this as child class name full width and size large and let's also add some features to this button here class name full width on click handle copy size large variant outline and let see how this looks like there we go we can now copy the number or we can click call now and that going to initiate the call so a very simple contact as screen right which will allow you to see the number copy it or directly call the number and the number that's displayed here is the exact one that you've selected here so the last number is 76 let's confirm 76 if i change this to 35 and click save settings let's refresh let's click into call us 35 there we go so again white labeling allows our customers to have much more freedom of expression when it comes to their voice features here amazing amazing job that's what i wanted us to do in this chapter quite a short one simple one uh and let's go ahead and review it and merge So 29 widget improvements. I'm going to go ahead and stage all of the changes. 29 widget improvements. Let's commit. After that, I'm going to open a new branch. 29 widget improvements. And then I'm just going to publish the branch. Now let's go ahead and review the pull request. so here I have opened the pull request from 29 widget improvements into our main let's click create a pull request here and let's wait for the review and since this was a very simple one we don't really have to go through the entire review we know the changes right only four files some css changes and a new screen with no actual you know business logic in there so we added a contact screen to the widget we can view the phone number, copy it with confirmation feedback or start a call instantly. We also include back navigation as well as the availability status which is 24-7 because they are AI support agents. And then we fixed the full viewport wrapper to the widget content. We updated the main container styling with rounded edges, border and improved overflow handling. We adjusted the voice screen transcript area sizing for more consistent layout and scrolling as well as simplified layout styling for overall consistency. Amazing, that's exactly what we did. Let's go ahead and merge this pull request and once we have merged it, let's go back and head to our main branch and then let's synchronize the changes. Once we synchronize the changes, let's double check within the graph to confirm everything is all right. So graph, there we go, 29 widget improvements. I believe that that marks the end of this chapter and see you in the next one. In this chapter we're going to develop the contact panel. Contact panel is part of the conversation ID layout which we already almost finished at this point but there is just one thing missing and it's this exact contact panel that we're going to be developing in this chapter. So I have TurboDev running which means I have my web available at localhost 3000. So in here, when I select a certain conversation, you can see that this is pretty much finished, but there is one thing missing, and that's this side panel here that shows me more information about this user. This will be quite easy to do as we already have all the necessary information in the user metadata. So let's start by creating a new layout that will be able to hold that panel here. So I'm going to go inside of apps, web, app, dashboard, conversations, conversation ID. And in here, I'm going to create layout.tsx. Let's go ahead and let's simply do layout. Let's go ahead and render the children inside so we can give this a type of react, react node. And inside of here, we can just render children like this. Make sure that you do a default export here and that the file is called layout. And if you've done this correctly, nothing should change. You can see I can refresh this and everything is exactly the same as it was. So just make sure you've done this inside of the conversation ID folder. And now what we're going to do is we're just going to go back inside of our modules. and let's go inside of dashboard here, UI layouts and let's create conversation ID layout.tsx and in here let's go ahead and export const conversation ID layout like so and inside of our newly created layout. Let's just copy the props so we can have them here too. And let's simply return a div and render the children. So now we no longer have to develop the layout in the app router. We can develop it here in the UI folder. I feel more comfortable doing that. So now I can remove the fragment and add the conversation ID layout import like that now i don't have to worry about designing here in the export default file which is a reserved file name and it's used exclusively for routing inside of this app folder instead i can focus on my module ui conversation id layout i like this better let me just see what the error is here i think this is just typescript server which needs to be restarted. Let's see if I'm correct. I am. Great. Now it's time to actually develop this because still you can see some slight changes, a bug, but let's fix it now. So we're going to need to add the resizable components here. So let's add resizable handle panel and panel group from workspace UI components resizable. This is from ShatCN UI. And let's go ahead and replace this with resizable. My apologies, this is resizable panel group. Let's give it a class name height full flex one and let's give it direction horizontal. Then let's go ahead and add a resizable panel here like this and render the children inside. Give this resizable panel a class name of height full and give it a default size of 60. And then inside of here, render the children inside of a div and give this div a class name, flex height full, flex one and flex column. And right now it should look exactly as it looked before. And now let's add the resizable handle, which is a self-closing tag and let's give it a class name of hidden but on large let's display it and now let's create another resizable panel here and in here we're going to have our contact panel so there we go this is the place where we're going to develop the user information Now let's just give it some more info here. So default size is going to be 40. Maximum size is going to be 40 as well. And minimum size will be 20. And in order to make this a little bit better, if you want to, you can add a class name hidden large block like this. and then if you are on not exactly mobile but on kind of in tablet mode it will not appear now I'm not sure if this is you know a good decision or not but I feel like this panel right here isn't as crucial as this panel and this panel right here because the sidebar can be hidden away so that's fine but this always needs to be here and this always needs to be here but the contact panel doesn't and if we're talking about actual mobile responsivity here I've taken a look at Intercom and Crisp and Zendesk all of them simply tell you to download their app right so looks like no one is really bothering with making the desktop app too responsive so I kind of feel this is an okay middle ground but just at least allowing tablet mode to be usable because if you don't do this and zoom in you can see it just becomes weird at this point so yeah maybe add this maybe don't however you prefer just make sure that you are zoomed out enough so that you can see the contact panel for now when we are developing it all right now that we have this it's time for us to actually create the contact panel. So we're going to do that in here instead of dashboard UI components and let's do contact panel dot dsx. Let's mark this as use client and let's export const contact contact panel. Inside of here let's simply return a div contact panel. and now let's go back in here and let's import that there we go just like that it's a self-closing tag again nothing much should really change here so now what I want us to do is actually well build the contact panel so let's give this div a class name flex height full with full flex call pg background and text foreground inside let's add a new div with a class name flex flex column gap y4 and padding of four inside of here another div with a class name flex items center gap x of two and in here we need our dice pair avatar component so make sure you add this import And now for the size, you're going to give it 42. But for the information such as badge, image URL, and image URL, and seed, we actually have no information to fetch. So let's go ahead and see if we have the necessary APIs to fetch the contact. So the first thing we have to do here is we have to somehow pass the conversation ID from our layout here all the way to the contact panel Now since this is a use client component we can actually do this in a different way rather than passing props We can leverage the params, use params hook from next navigation. And from here, we can get the conversation ID using params.conversationID like that. And let's go ahead and try and simply logging this somewhere. So I'm just going to try and do conversation ID here. Now let's see if it works. And there we go. You can see it right here. And it's the exact conversation ID that's selected. So you can see how when I change the conversation ID changes, right? Perfect. Now that we have that, let's go ahead and let's see, do we have the necessary functions to fetch this contact? So I'm going to check inside of my packages here, backend, convex. this is private. And let's see, it looks like we have nothing in regards to the contact sessions. So let's go ahead inside of the private here and let's create contact sessions.ts. And inside of here, I'm going to export const get one by conversation ID. Why by conversation ID? Because that's the only thing I have in this component. So it makes no sense to fetch this by the contact accession ID because I don't have that, right? So this will be a query accepting the arguments for conversation ID, which is a type of v.id conversations, right? That's the one we have. Now let's go ahead and prepare this. Let's fix the typo asynchronous. Let's import the query from the generated server. Let's get the context and the arguments. There we go. Now in here, First things first, let's check that we have the identity from context out get user identity. And let's replace this with convex error from convex values. And let's go ahead and do what we usually do. I like to throw an object. I think it's better. So code is unauthorized and message can be unauthorized like this. And let me just copy this. paste it here so this will be unauthorized the message will be organization not found so once we have confirmed that we are authorized to attempt to fetch this let's check if this actually belongs to our organization ID so let me check my schema here and let's see the contact sessions here we have the oh looks like each contact session has the organization id here that's very interesting so i just want to check something contact sessions i should have them inside of my convex public folder so i'm just going to check how we create it so we pass the organization id okay that's very interesting uh that means that we should be able to fetch it with an index. Is that true? I think it is. We have by organization ID here for the contact sessions. Perfect. So I'm going to go ahead inside of the private which we are developing here. Let's do const contact session and let's go ahead and do await context database query, contact sessions and just let me remind myself how do we write, is it just with index? It is with index. It's going to be by organization ID. So let me just collapse this. So query with index. Let's get the query. Query dot equals organization ID. And this will simply be organization ID like this. And dot unique because is it dot unique? actually, yeah, I'm not sure this can work like that because you can have different contact sessions within an organization. Yes, this is not the way we should be doing this. Let me just think again. How about we go back inside of the contact sessions schema here? Let's go in here. So we have by organization ID. why do we not have we should also have the by just check so we can fetch the conversation and then we have the contact session ID okay let's do that first since we are technically working with the conversation ID here not the most elegant solution but yeah let's do it like that for now conversation will be await context.database.get and pass in the arguments.conversationID. If there is no conversation, in that case, I'm just going to throw an error here. Not found. Conversation not found. And then another important check. If conversation.organizationID is different from my current organizationID, in that case, I'm unauthorized to fetch this. So unauthorized invalid organization ID. And now what we can do is we can fetch the contact session by doing await context database.get conversation.contactsessionID. And we can return the contact session. I think that should be just fine. Great. so this is now a private function which allows us to fetch the contact session by their conversation id and it's protected so only certain members of the organization can fetch it now let's go back instead of the contact panel let's get the contact session from use query from convex react API from workspace backend generated API dot private dot contact sessions get one by conversation ID and conversation let's pass in the conversation ID to be well the conversation ID from above and we can add as ID here from workspace backend generated data model and pass in the conversations here like this and just in case, yeah, because this can technically be ID or in some, I don't see how it's possible for this to be null because it's used within a layout that needs to have it but just in case, we can check if we have conversation ID in that case, let's do, let me just fix this like this. So we're doing our usual check. If we have conversation ID, then use it. Otherwise, skip. There we go. So even if it's null, it will work. Now let's go ahead and let's do a simple loading method here. So if contact session is undefined, which basically means this is loading, what I think we should render. Let's simply render null. Let's see. Yeah, that looks fine. No need to have a skeleton for every single part. I think this is okay because it will be loaded quite quickly. Now that we have the contact session, let's go ahead and let's try and add some information here. So the seed here will be conversation. my apologies contact session and let's pass in the underscore ID. Okay. Or if contact, no. There we go. This way we can safely use it at this point. So now we can pass the seed and the image will now match exactly the user. You can see. that's what we wanted to achieve great now for the image url actually we don't have anything to pass but for the badge image url we should be able to pass in the user's country info but in order to do that we first need to generate the user's country info so just above here let's do const country info and let's add that to use memo from react so make sure you import that and inside of here let's go ahead and let's return get country from time zone this is the util that we developed in not sure which chapter but definitely when we wanted to display these flags here so get country from time zone and in here simply pass the contact session question mark metadata question mark and time zone I think is the one we need yes and then in here go ahead and add the contact session metadata time zone and now we have the country info here now that we have the country info we should be able to pass this in here so let's go ahead and do country info question mark dot code if it's available let's do get country get country flag url so make sure to import that as well from the same lib and pass in the country info dot code in here otherwise is undefined. So getCountry flag URL is from the same util. And now, there we go, you can see the flag for that user here so you know where they're from. Perfect. Now that we have that, let's go ahead and render some more user information here. So just below the DiceBear avatar, I'm going to open a new div with a class name flex1on.\noverflow hidden div with the class name flex items center gap x of two and h4 contact session dot name and the class name line clamp one there we go now we have the user's name here. Outside of this div, let's add a paragraph rendering the user's email. So contact session dot email. There we go. Let's go ahead and style this paragraph here by giving it line clamp one as well. Text muted foreground and text small. Perfect. Now, outside of this div right here we used to render this, let's add a button. So make sure to import the button from Workspace UI Components button. And in here let's add link from next forward slash link. And we're going to render mail icon from Lucid React and with the text send email. And we're going to give this an href of mail to and then simply contact session dot email. let's give this an as child prop class name of full width and the size of large there we go we now have a nice button which will send which will actually open your email app and automatically append the to to be this email so that's what this does great now that we have that it's time for us to render the user's metadata so in order to do that we first need to import all the accordion items so accordion accordion content item and trigger from workspace ui components accordion and now we would need to add bowser into our app here so let's go ahead in the root of our app to pnpmf this is web add and let's do bowser here I'm going to show you the exact version that I'm using in case you want to be on the same page as me but I'm not sure how often this version even changes so let's see exactly what I added here package.json apps web bowser 2.12.0 that is the version I am using now that I have browser I can import that too and that will allow me to parse the metadata in a kind of a more reliable way here so let's prepare a few functions for that just above the country info I'm going to prepare const parse user agent which will be use memo and in here I'm going to return user agent question mark string if there is no user agent I'm going to return browser unknown os unknown device unknown. Otherwise I will attempt to get the user's browser by using bowser.getParserUserAgent. I will attempt to get the result from browser.getResult. once I have that I can return back an object with a bunch of information so you can of course limit how much information you actually want to show but I'm going to show you what you can extract from the user agent you can extract the browser using the result.browser.name or fall back to unknown you can do the same thing with the browser version with the os with the os version and same thing for the device the user is on such as desktop, mobile, right? Then you can do the same thing for the device vendor and the device model. So the more information you have as a customer support agent, the easier it will be to debug what is actually happening to your user here. And now in here, let's go ahead and do one more function. So const user agent info is another use memo. And this one will very simply call the parse user agent function from above. And it's going to pass in the contact session dot metadata dot user agent. And let me just add this. There we go. And this will also be in the dependency array here. So let me just collapse this here. So this and parse user agent function from above like this. Just make sure to immediately return and call this function here. Now that we have the user agent info, we are kind of ready to start showing some information here. So let me just try and JSON stringify this for now. So outside of this div, this is the place in this new div where we're going to render all of that. So let's try and stringify the user agent info. I think that's the one. That's the final one. Is it? It is, I think. So let me try and refresh. Yes, you have to refresh because we shut down our app to install Bowser. So it has to rebuild now. Let's give it a second. There we go. So browser, Chrome, browser version, OS, Mac, OS, devices, desktop, device vendor is Apple, right? So some useful information about the user. Of course, when you actually deploy this to production, you will have to abide by the laws, right? You will have to check, are you allowed to fetch that or not, of course. But for educational purposes, I'm just teaching you how you can do this. Great. Now that we have that, let's build a modular way for us to build these accordions so that they can just read from our metadata object and automatically populate the information inside when the user clicks. In order to do that, we're going to have to prepare some types first. So let's go ahead and let's add a type info item, which has a label, which is a required string, a value, which can be a string or a JSX or React node and an optional class name. And let's also add another type called info selection with an ID of string and an icon of react component type, which holds a class name, a title, and items, which is an array of info items from above. And now that we have that, let's go ahead and let's build accordion sections array. So just before you do any returns, let's go ahead and do const accordion sections, use memo, info section. Like so. There we go. And now in here, first, let's do. If there is no contact session dot metadata, we have nothing to return except an empty array. So let's return it like that. Otherwise, let's return an actual array. The first thing we'll do is give it an ID of device info. And in here, we're going to have an icon of monitor icon. So all icons will be imported from Lucid React, so make sure you add them. The title here will be device information. And then the items inside will be another array. The first item will be the label browser. and the value here will be useragentinfo.browser and now you just have to kind of append some strings to make it look better, right? So plus and then open parenthesis useragentinfo.browserVersion if the version exists, go ahead and append it like this useragentinfo.browserVersion if it doesn't just use an empty string after that let's add a label os value and then same thing we're kind of appending the os version if it exists so user agent info dot os plus if the os version exists append the os version otherwise add an empty string you can also just do this I'm just showing you a way that you can add a few more strings to here and you can display the OS version next to the OS if it exists but we need to do it in this way I'm leaving space here in purpose so it shows the OS, then a space, and then the OS version but we also have to manually fall back to this so it doesn't actually display null or undefined in the string because that looks bad that's why we are using ternary here right same thing that we're doing here I just collapsed it for readability here okay now that I have at least something here I want to already start and attempt to render this just so we see what we're building so in here I'm going to check if conversation my apologies if contact session.metadata exists in that case let's add accordion here let's give it class name full width rounded none border y let's give it a prop collapsible and the type of it can be multiple it can be single whatever you prefer and let me just see so collapsible does not exist maybe it needs to be single then yes okay and then let's do accordionsections.map and then let's render a section individually. So accordionItem will be used to render the section and let go ahead and give it a key of section Let give it a value of section And inside of here, let's add accordion trigger. And let's go ahead and render a div. Let's give this a class name, flex items center and gap of four. Let's render section.icon. which is a self-closing tag, give it a class name size 4 and shrink 0, and then a span rendering section.title and give this a class name. Actually, no need for any class name. Like this. So let's try it out. There we go. Device information, and when you click, looks like it's not really opening anything because we didn't develop the accordion content. So just below the accordion trigger, add accordion content here. This will have a class name of px5 and py4, a div inside with a class name space y2 and text small and in here simply do section.items.map, get the item, render a div here and then render a span item.label and then add like a little column here. Give this span a class name of text muted foreground. Give this div a class name of flex justify between. Give it a key of section. Okay, let's do section.id dash section.label so it's unique. item.label. Whoops. And then in here another span rendering the item.value and the class name of item.className. There we go. And now you can see the browser version and the OS as well as the version under the device information accordion. And now let's go ahead and just improve the styling of the accordion a bit so we're only going to do this once and then we're just going to copy our accordions as we need them so for the accordion item open a class name and add rounded none outline none has focus visible z10 has focus visible border ring has focus visible ring-open square brackets three pixels has focus visible ring-ring forward slash 50. Now for the accordion trigger give it a class name of flex full width flex one items start justify between, gap 4, rounded, none, bg, accent, px 5, py 4, text left, font medium, text small, outline none transition all hover no dash underline disabled pointer events none disabled opacity 50 and i believe that's all we have to do there we go this is the exact styling that we need and now we can just copy and paste this actually no we don't have to copy and paste anything that's it we just have now populate our accordion sections to have more info if you're satisfied with how this looks already you can just skip to the next chapter but I will show you how you can add some more information here because I'm sure some of you do want to see that so after OS let's go ahead and let's add label device. Let's go ahead and give this a value and then the same thing, right? So user agent info dot device plus user agent info dot device model. But let's collapse this like so. So if device model exists, open backticks space dash space and then user agent info dot device model. Otherwise empty string. This is model. There we go. And we can also use the class name prop here to, for example, capitalize this. And now you can see that the desktop shows capitalized like this, whereas macOS doesn't, because it looks kind of weird if desktop is lowercase. Perfect. Now let's go ahead and do some easier ones. So for example, label screen value would be contact session.metadata.screenResolution. Let's go ahead and copy this one. The next one would be viewport, viewport size. Then we would have cookies, cookie enabled. and it's actually better to just use a ternary here. So if it's enabled, show enabled, otherwise show disabled. So now you have cookies, viewport, much more information here, right? Now let's go ahead and add a whole new section here. So let me just check. Okay, so you have to go where this device info object ends right here and add a new one. Give it an ID, location, info, icon, globe icon from Lucid React, title, location and language. And give it items, spread country info. Whoops. Like that. if country info exists, open an array, otherwise it's going to be an empty array. So in here, let's add label country and for the value, let's go ahead and let's render a span country info dot name and let's give this a class name flex. Actually, we don't mean anything. I think just this is enough. There we go. If you want, you can also add some flag here, right? And now let's also add a few more elements in here. So after this, label language, value, let's go ahead and use contact session.metadata.language. Let's copy this. Let's add time zone. Let's copy this. And last one, let's do UTC offset. Time zone offset. Let's see. Yeah, you can do it like that or maybe you can humanize it a bit. maybe open this so this is minus divide by 60 and turn it into hours did I do this correctly let me just check context action okay maybe we don't need this no need to complicate this chapter any further I think you get the point right basically all of the things that oh did they all just disappear now how did I do that exactly? Let me just debug what's going on here. Oh yeah, one important thing and why this is actually happening is because this is missing some dependency array here. So let's add contact session. Let's add user agent info. And let's add country info. And there we go. now you will always have some things here so yes you can now add in here pretty much everything you want from your user metadata in the exact same way that i just added here so you have the platform the vendors screen resolution time zone refer current url and you can separate that into whatever section you want i don't think it makes much sense for you to watch me uh populate all of those fields for another 15 minutes because they're exactly the same as I just did this. I think this is good enough. It shows the most useful information where the user is from as well as their device information. And if you want to add even more fields, you can of course add even more fields inside of these accordion sections. Perfect. So now let's go ahead and merge this. So let's see, we created a new layout we created a contact panel now let's commit the changes and let's merge this pull request so yes you can see in here we have session details so how would you do that well the exact same way right you would just add another property here with an id section details the label would be section details the icon would be clock icon and the items let's make it an empty array like this do I need some items inside let's see let's just add at least one so we have at least those three sections so label session started value new date contact session dot underscore creation time to locale string. Let me just see if I did this correctly or not. It seems like I made a mistake somewhere. Just a second. So this ends here. Location info ends here. and then a new one is started Let see what did I do wrong It title not label There we go And now you have section details right So you can add as many of these elements as you want here. Perfect. Now that we have this, let's go ahead and stage all of those changes. So I'm going to stage all changes. I'm going to add a message 30 contact panel I'm going to commit and I'm going to create a new branch 30 contact panel I'm going to publish branch and now let's open a pull request and let's review so I'm creating a new pull request here and let's see all the changes with it and here we have the summary we introduced a resizable split view layout on conversation pages we added a contact panel showing device browser os screen viewport location language time zone utc offset as well as some session information all of this was done using our metadata and user agent string. In here we also have a sequence diagram explaining how our new function get1 by conversation ID works as well as no actual comments other than some nitpick ones meaning we did a pretty good job with this one. So let's go ahead and merge this pull request and then let's go back inside of our IDE, switch to our main branch and click synchronize changes. This way our main local branch is up to date with our main remote branch. As always check the graph to confirm that you just merged in chapter 30 contact panel. I believe that marks the end of this chapter. Amazing job and see you in the next one. In this chapter working to add subscriptions to our app. This will include creating the subscriptions backend schema, creating the subscription functions so that we can protect our API routes, creating a UI protection so users can't see pages which are only meant to be seen by premium users, and we're also going to add the dedicated billing page. All of this will be done quite easily thanks to clerk billing. But let's start with the simplest part, creating the subscriptions schema. So we can synchronize Convex database with clerk billing. Make sure you have your app running. Make sure you're on your main branch. Now let's go ahead inside of our apps. Let's go inside of packages, backend, Convex. And let's head inside of schema. And for my latest table, I'm going to add subscriptions. Let's go ahead and define this table. In here, let's set the organization ID to be a type of string. Let's set the status to be a type of string as well. And now let's just add an index here. By organization ID using the organization ID field. That's the only thing we need. Make sure that you visit your backend app here and just confirm that it compiles normally and it pushes the table. Now that we've added our backend schema, let's enable billing on clerk. So head to the clerk dashboard and in here you will have a subscriptions tab. If you don't have it here, head inside of configure and scroll under the billing section. In here, head inside of the settings. So billing settings and let's go ahead and make sure that we enable this. So let's see, instead of subscription plans, if we click get started, that will enable the subscriptions, I believe, or if you click here. I think basically you need to create a plan and then this will be considered as enabled. So let's go ahead and create a plan here. And let's go ahead and let's add an organization plan, right? So we are working with plans for organizations. Now, this will only appear for you if you have organizations enabled, right? If you don't have organizations enabled, I think you will only see the option plans for users. But since we are building a B2B app, our subscription will work per organization, right? So each organization will have their subscription plan. So yes, we are going to have one which is free. it was automatically created now let's click add plan and let's add a premium one so this will be called pro the key can stay pro uh and in the description i'm not sure we have to add anything let's go ahead and let's add 29 as the monthly base fee here and let's just click save for now there we go so now inside of your subscription plans here you should have the free plan and you should also have the pro plan now that you have that make sure to click enable billing here if you have that button make sure that you see the message billing is enabled you can also double check in the settings just to see that billing is enabled right here and make sure that you have clerk payment getaway selected excellent now that we have that let's go ahead back inside of localhost 3000 here and let's go ahead and let's develop a very simple pricing uh view so the pricing view will be here instead of plans and billing which currently is just an empty billing page so let's go ahead and find that so billing page dot the sx here we go instead of app instead of apps web app dashboard billing page.tsx. So let's go ahead now inside of our modules let's create a new folder called billing. Inside of here let's create UI views and let's create billing view.tsx. Let's mark this as use client and export const billing view. Inside of here, let's return a div with a class name flex minimum height of screen, flex column, background muted and padding 8. Another div inside with a class name mxauto, full width, maximum width screen, medium. Another div inside with a class name space Y2. An H1 element plans and billing. And a class name text to Excel and the text for Excel. Below this heading let's add a paragraph. Choose the plan that's right for you. and you can also replace this with ethos like that and now let's go ahead and back inside of this page and let's actually use the billing view import there we go plans and billing choose the plan that's right for you instead of the billing view we now have to build the pricing table so this is how we're going to do that outside of this div let's start a new one and give it a class name margin top of 8 and inside pricing table. Let's not import this from anywhere. Instead, we're going to create the components folder inside of this UI and let's add pricing-table.tsx. Let's mark this as use client as well. Let's import pricing table from clerknext.js as clerk pricing table so we alias it. The reason we are aliasing it is because we are going to have a constant called pricing table here. So if we didn't alias it this is a conflict. So that's why we are aliasing the import. Now in here let's go ahead and let's return a div class name flex flex column items center justify center gap y of 4 and let's add the clerk pricing table. Let's add one more thing here for organizations. Now let's import the pricing table not from clerk next.js but from our components pricing table. Let's refresh and there we go. You now have the free tier and you have the new pro tier that we created and if you click subscribe you will see how easy it is to subscribe i would recommend not doing it right now simply because we are going to test the free tier now but if you accidentally subscribe no worries just switch to an organization where you don't have the subscription or just create a new organization as simple as that right just make sure you are testing on some organization that doesn't have pro so it's easier for you to look at the free tier and how it's going to look like. Excellent. Now in order to make this look a little bit better, what I suggest we do is we go inside of the clerk here, go inside of My Apologies, inside of Configure, Subscription Plans, and let's go inside of Pro. And let's go ahead and give it some features here so it will look better. So for the features, Let's add AI customer support. Let's click create feature. Let's add AI voice agent. And just to put some ease of your mind, this isn't required. So yes, you can use this in a much better way than I'm doing it. I'm only doing it because when you add features and when they are marked as publicly available and when you refresh your pricing table they will appear here that's the only reason i'm doing it usually you could do it like if you had multiple plans and some plan offers voice agent another plan offers ai customer support and then you would pick and choose what feature the organization has so yes you could technically just look for individual feature that some user has but in my case I just using it as a nice ui so phone system let add that feature knowledge base let add that as well and let add team access because remember even though we using clerk organizations we have limited our organization users to just one user let's revisit that quickly. Organization management settings, I believe. There we go. Default membership limit, limited membership one. Why one? Whoops. So keep this as one. Why one? Well, very simple, because clerks billing, let me see if I can find the exact, so these are the docs, But basically, clerk's organization has its costs only when it has two or more monthly active users within an organization. So for that reason, if we just allow users to create new organizations for themselves and be alone inside, this does not make clerk costs any higher. right so in this specific scenario these organizations are completely free for us and for our users so in order to allow our users to invite new people right so if i go ahead and add antonio at example.com you can see that this will now fail because we have a limit of one so only once they upgrade only once we are actually making money from this user does it make sense to allow them to invite new people because that will occur more costs on our side so this is how we're going to make sure that our third-party integration in this case clerk is self-sustainable and actually profitable for us so that's always something that you have to think about great so let me just refresh in here so we can see all the new features that we are going to get here amazing uh and yes you can also add some things in the the free plan if you want to but i think this kind of looks okay for now great so now let's go ahead and just style these cards a little bit better instead of the pricing table here so let's add appearance elements pricing table card shadow none border rounded large pricing table card header bg background and let's copy this a few more times the next one will be pricing table card body and pricing table card footer there we go now this kind of looks like our style now what I want to do is I want to no actually I'm okay with as it is right now I wanted to change the color of this but I'm trying to think of the best way to do that I think that we have to go inside of the layout instead of our web application and we have to find the clerk provider and in the clerk provider here there should be an option to add appearance variables and change the color primary now it cannot be a tailwind color it needs to be a hex color so that is 3c8 to f6 and then there we go Now the entire clerk is themed in our color. So I like this better. Great. So that's the billing page finished for us. But now it would be nice if we could prevent users who are on their free tier from visually even seeing the widget customization, the voice assistant, right? I want all of that to kind of appear blocked or locked for free users. And they have an option to get redirected to this pro plan and then they have to upgrade and then they'll have access to the knowledge base and such. So let's go ahead and figure out how we can do that. So the way that we can protect the UI is actually very, very easy. Again, thanks to Clerk and the fact that we are using their billing. so if you go inside of web app dashboard and let's see what do we want to protect well i think we can start with files because it's the first thing here the knowledge base right and let's go inside of its page.dsx and in here i'm going to import protect from clerk next js and that's all i'm going to do for now actually and i'm going to modify the return a little bit so this time i return protect and I will render files view inside and the condition will be if has plan pro so how do I know I need to type pro here it's very simple it is because inside of my billing subscription plans let me refresh here so i can find my pro plan find the key so the key is what's used in your code base to refer to this plan so if this was pro one two three i would have to write pro one two three here so check what the key stands for if you wrote it exactly as me it should be pro and the capitalization matters so make sure that you write it like this so if the user has i mean if the organization has plan pro then show the files a view otherwise let's go ahead and let's show uh you need to upgrade like this there we go you need to upgrade right but if i change this to let me see what is the name of our free plan right so we always have to check that inside of free free underscore org so let me check with that and now i can again see the knowledge base because i changed this to be the free plan but let's keep it pro for now and now let's make a little bit of a nicer overlay here so that this kind of a lock screen looks better in order to do that let's implement the feature called the premium feature overlay we're going to do that inside of the billing modules instead of ui components let's add premium feature overlay dot t s x let's mark this as use client and let's import a bunch of things from lucid react starting with type lucid icon and now let's add all the other icons we're going to need so book open icon bot icon gem icon and now let's add microphone icon palette icon phone icon and users icon let's add use router from next navigation let's import the button from workspace ui components button and let's import everything we need from our card including the card, content, description, header and the title. Now let's create some types so we have modular display of the features that user will unlock when they upgrade. So interface feature will have an icon of lucid icon, label of string and the description of string and let's create another interface premium feature overlay props will only accept children now let's go ahead and create a constant called features which will be a type of feature and an array of those like that now let's go ahead and export const premium feature overlay let's assign the props let's extract the props now let's render something here so I'm going to render a div with a class name relative and a minimum height of screen then I'm going to create blurred background content this will render the children and I'm going to give this pointer events none select none and blur two pixels below that I'm going to add an overlay an overlay is going to be a self-closing div. It's going to have a class name of absolute inset zero background black with 50% opacity backdrop, oops, backdrop blur two pixels. And that's it. and now in here i'm going to add the upgrade prompt so i think that already we should be able to see this so let's quickly go back inside of the page and let's see how we're going to use this so let's import premium feature overlay from modules billing ui and inside of the fallback let's do the following render premium feature overlay and then inside render the files view like this and now this is what will happen we kind of give the user a sneak peek of what's behind but we don't let them interact with this and if you think oh but isn't this a security issue we are rendering the component, no. Rendering UI things shouldn't ever allow your user to actually do something. Our API routes, our functions are very well protected. I mean, you definitely know that we check the user identity and the organization ID in every single private API route or API function, if you want to call them that way. So you don't have to worry about importing or rendering a component that a premium user shouldn't see. That's perfectly\nfine right we're just going to check on the back end if they're allowed to do this or not uh but yeah this is what i kind of want to do i want to give them a sneak peek and then i'm going to give them a prompt if you want to see what's fully behind upgrade and that's what we're going to do now so the upgrade prompt will be a div with a class name absolute inset zero z index of 40 flex items center justify center and padding 4. Then let's add card class name full width maximum width medium. Then let's add card header. Let's give the card header a class name of text center. let's create a div with a class name flex items center justify center and another div with a class name margin bottom of 2 inline flex height 12 width 12 items center justify center rounded full border background muted and inside render the gem icon which we previously imported give the gem icon a class name of size 6 and text muted foreground there we go so a little model like component with a gem which represents kind of this is premium right and outside of these two divs still inside of the card header add the card title which will say premium feature and give this a class name text extra large. Then below that card description, this feature requires a pro subscription. There we go. Outside of card header, add card content with a class name space Y6. and in here we're going to render the features list so this will be a div with a class name space y six and now let's add at least one feature in here in the features array for example let's add ai customer support so object icon bot icon label ai customer support description intelligent automated responses 24 7. Now that we have that let's go ahead inside of here and let's iterate over our features so features.map get the individual feature and let's go ahead and render something inside so this will be a div with a key of feature.label, class name flex items center gap 3. Inside of here, a div with a class name flex size 8 items center, justify center, rounded large, border, and background muted. inside let's render feature dot icon and the class name size 4 text muted foreground there we go so first feature is starting to appear outside of that div let's create a new one with a class name text left inside of that div let's go ahead and create a paragraph which will render the feature label and another paragraph which will render the feature description. Now let's style those paragraphs. The first one will have a class name font medium and text small. And the second one will have a class name text muted foreground and text extra small. Great. Now outside of this div still inside of the card content let's add the button. let's say view plans and let's give some attributes to the button starting with the class name full width on click for now an empty arrow function and size of large now in order to make this button redirect to somewhere let's prepare our router hook here const router use router we already have the router here imported from next navigation and now what we have to do is router.push billing and there we go now you can click here and you get redirected to the billing and now let's just go ahead and add as many features as we want here so the next one can be a voice agent feature so use the microphone icon ai voice agent with this description you can pause the screen and then copy with me if you want phone icon phone system inbound and outbound calling capabilities then let's add the knowledge base book open icon knowledge base train ai on your documentation let's add the team access now so icon users icon label team access description up to five operators per organization. Now let's add widget customization, customize your chat widget appearance using the palette icon. And now it should look like this. If you think it's too much, you can of course remove some of them because they will see the plans here again. I just think it's kind of worth it to create a separate component for this. It looks kind of cool, at least in my opinion and now we just have to repeat this for widget customization and for voice assistance so let's go ahead and do it inside of the widget customization so customization page so that's inside of dashboard customization page.tsx let's import premium feature overlay let's import protect from clerk next js and i'm just going to copy this entire thing here and replace it here like this and i'm just going to replace the files view with the customization view and now there we go you can see that this is a protected premium feature now yeah this doesn't look the best that you have to scroll uh yeah but i'm pretty sure you can fix that somehow maybe disabling the scroll within the premium feature overlay would be one way to do it not sure yeah but at least something to give the users a sneak peek and then like click here to upgrade right and we also have to do that in the voice assistant right here so i'm going to copy this thing again. Let's go inside of Vapi page.tsx so inside of plugins Vapi page.tsx Okay, let me just remove the double return and now let me just import protect from clerknext.js premium feature overlay and replace files view with Vapi view. so now we have a nice little reusable way to protect uh protect our ui things right so only premium users uh should be able to see these things and if you want to test it out let's try and subscribe right you can just click pay with test card if you're in development mode of course and there we go you can see it's instant and let's try and click inside of knowledge base I just realized they have a typo, knowledge base. But yeah, you can see now it works. You can see them. And if you switch to some organization that doesn't have the pro tier, they can't access it. So our billing is scoped per organization. B2B app, exactly what we wanted. So let me just fix the knowledge base issues, sidebar, dashboard dash, sidebar, knowledge base. There we go. Perfect. Excellent. So now if you're wondering where does this subscription appear, it appears in your clerk subscriptions tab right here. There we go. Revenue 29, monthly recurring revenue 29. This organization is now on pro plan. Perfect. So we did the subscription schema and we added the UI protection and we added the billing page. Now let's develop the subscription functions. so usually uh how i would attempt to do this how i would attempt to connect a convex backend with clerk subscription state is by visiting the jvt template right convex right here and then in here i would search for organization subscription right but as you can see that currently doesn't exists it's not something that we can add in our token it would be nice if we could kind of add if organization is on pro plan because if we could do that we technically wouldn't even have to have our subscription schema table right but because of that reason because we can't add them we have to manually keep track of when user subscribes so yes right now we actually subscribed but we never actually created the subscription table because we don't have the webhook set up but we will do that we'll do that next let's first finish the subscription functions so I'm going to go inside of my packages backend convex system and in here I will create subscriptions.ts let's import v from convex values let's import internal mutation and internal query from generated server and let's export const absurd which will be internal mutation. The arguments this will accept is organization id which is a type of string and status which is a type of string Let go ahead and set the handler here In the handler we accept the context and the arguments And let check if we have the existing subscription by using await context dot database dot query subscriptions with index by organization ID. And let's query organization ID arguments organization ID and let's pick the unique one so only one organization only one subscription per organization can exist if existing subscription is true meaning if it exists what we're going to do is we're going to patch it we're updating it then so let's patch it and let's simply update the status of that organization otherwise we are creating it for the first time. So we are inserting inside of subscriptions with organization ID and with status for the very first time. So this is the function that we are going to use to create or update the status of someone's subscription through our webhook. But we need to prepare that here in the internal mutation so that we can actually call it within a webhook. Now that we are here, let's also attempt to fetch a subscription internally. So expert const get by organization ID, internal query, arguments, organization ID, string, handler asynchronous let's get the context and the arguments and we're going to do a very simple thing this so let's just copy and paste it here in fact we can return await context database subscriptions by organization ID arguments organization ID unique so this is going to be our internal query that we're going to use throughout our other API routes to confirm whether this organization that the user has in their identity token has subscription or not. Perfect. So right now, if you go and visit your convex.dev here, and if you log in, you will see that your dashboard subscriptions table is completely empty, right? So make sure you're inside of app here. Make sure that you have this open, the tables, click inside of subscriptions, and you will see it's empty because we never ever create a subscription. So how do we create subscriptions? We now have the functions, but how do we actually create the subscriptions when someone upgrades for the first time? Well, we do that using webhooks. so in order to set up webhooks within convex and connect them to clerk there are a couple of steps we have to do and you can find a very very good boilerplate example by heading to convex docs and search for clerk and webhook they don't have the exact thing that we need here but they have a pretty similar one which is storing users in the convex database so we don't really have a need for this but if we did we could also leverage the webhook for that but in here if you scroll after their initial thing so what do they do here first they create the user's table schema you can think of this as our subscriptions table right then they create mutations we did this too is right now instead of the system subscriptions the internal queries right and now we are skipping this we're skipping this we're skipping this and scroll down until you find setup webhooks because this is what we are interested in. So in order to set up webhooks, usually you would need to somehow start a local tunnel to give clerk webhooks access to your app. But since we're using Convex, Convex is already a cloud. Convex can already contact clerk and vice versa. So all we have to do is find our Convex webhook endpoint. So in order to do that, we have to find the .site URL. And there are a couple of ways you can do that. In here you can see your deployment name in the .environment local file in your project directory or your convex dashboard as part of the deployment URL. So let's go ahead and check inside of our convex folder here. In the back end we have .environment.local and you can see that I have the convex URL here so that's one way we can do it but keep in mind in here it says it needs to end in dot site not dot cloud i'm pretty sure all we have to do is just change this to dot site but let's see if there is another way we can find it uh perhaps by going inside of the settings here uh and let's see if i click show development credentials there we go http actions url this is the one we need let me just check is it exactly as i thought convex url yes it's exactly the same it's just not dot cloud it's dot site so find this http actions url and then let's get inside of clerk let's head inside of the dashboard again and in here let's go inside of configure and let's find the web hooks in the webhooks here let's go ahead and click add endpoint let's add it and let's do forward slash clerk dash webhook so this is the new endpoint that we're going to create later on but this is important hdbs and then convex.site like this now the events that we're going to subscribe to is going to be in my case subscription events like this in fact the only one that i will listen to is dot updated but i just want to bring to your attention that you can manually select all of this if you want to so i'm only interested in the subscription dot updated event that's the only one i need uh in here i don't think i need anything so i will just click create like this great now that we have that we need the signing secret right here so let's copy the signing secret that's very important so click here to reveal it and then copy it entirely and first things first let's add it to our packages backend environment.local so i'm going to add uh oh do we already uh have the oh my apologies no this is not the one uh i okay i was searching for what's the correct name to store this into. So clerk webhook secret. And let's add it here. So just after clerk secret key. And once you've added it here, super important. Also go inside of your project settings, environment variables, add and added here clerk webhook secret and save. there we go now you are ready to actually receive something here so what we're gonna have to do before we can continue is just add a small package so pnpmf backend adds sfix so clerk uses sfix to handle their webhooks so we need it here in order to authorize the headers from the webhooks so make sure you have added that inside of your backend and once you've done that let's go ahead and let's create our endpoint so inside of packages backend convex create a new file http.ts exactly like this it's a reserved file and now in here let's go ahead and let's create the router so const http will be HTTP router from convex server like this and make sure that you do export default HTTP and once you do this I think that there shouldn't be any errors let's see running type script finalizing push there we go so convex must have a default export of the router we just added that So everything is fine now. Great. And now let's add our first route. So similarly to Express or Hono, HTTP.route path clerk-webhook method post handler HTTP action which you can import from generated server asynchronous context request like that. And then in here, let's go ahead and let's first validate the event and then listen to subscription.updated. And then we're going to call our system subscriptions absurd internal mutation so that we either create the new subscription table or update existing. So every time the organization updates, my apologies, upgrades, this webhook is going to fire. So it's important that this clerk-webhook matches exactly how we name our route here, clerk-webhook. So if I change this to 1, 2, 3, I also have to modify this to 1, 2, 3. But for now, just leave it to be normal. So the first thing we're going to have to do here is create a validate request method. In order to do that, let's go ahead and import something from Svix, which we just recently added to our project. So import webhook from Svix like this. And let's also import create clerk client from clerk backend. And I think let's also add type webhook event from clerk backend. now let's go ahead and create this validate request function so i'm going to do that here const actually let's do asynchronous function validate request it will accept the request which is a type of normal request and it will return back a promise the promise will either have a webhook event type or null. So we basically have to validate that whoever is trying to access this clerk webhook endpoint is authorized to access it And the only person the only thing that available to access this is clerk so we have to make sure that their headers match exactly what needs to be decrypted when decrypted with our clerk webhook secret so there are no chances anyone can access this but clerk who we expect to access this right so let's go ahead and do const payload string await request dot text so we turn it into a string now let's define the swix headers let's start with swix dash id request headers dot get swix dash id or empty string be super careful here there are no type safety here So make sure you don't misspell sfix-id, sfix-id. Let's copy this and let's change this to sfix timestamp and sfix timestamp2. Then let's do sfix signature and sfix signature here. So double, triple, quadruple check that you don't have any typos here because it's going to be hard for you to debug why your requests are failing. Now let's attempt to get the webhook by doing new webhook process.environment clerk webhook secret. Like so. The clerk webhook secret is what we've just added here in the environment local and what you absolutely need to have in here as well. So we copy that from here, the signing secret. Perfect. Now that we have this, let's go ahead and do a try and a catch. Let's first do the catch. So console.error, error, verifying, webhook event, error. Actually, let's not do it within a string. Let's just add it like this. And let's return no. And in the try, let's do return webhook.verify payload string, Svix headers as unknown as webhook event like this. So now we have an official way of verifying that whoever is trying to access this is coming from clerk. So we can now go back instead of our HTTP action. and let's do const event and attempt to get the event using await validate request which we just wrote and pass in the request so this will either throw an error or this will either return null or it will allow us to have the event and then we can check if the event type is subscription updated so in case there is no event it means either an invalid event has been sent or someone malicious is trying to access this. So we need to treat this as if an unauthorized user is attempting to access an authorized route. So return new response, error occurred. And status 400. We don't let anyone past this point. Otherwise, if we do get a valid event, that can only happen if it's actually clerk who sent this event. So let's switch event.type case subscription.updated because that's the only one we are listening to. So constant subscription will be event.data as status string payer, which is an object, an optional object, which has organization underscore ID. So clerk uses this casing. they don't use the same casing as we do so be careful how do i know that it's payer and how do i know it's this if you go inside of clerk here if you go inside of event catalog search for subscription search for subscription.updated it will scroll down to here open that go inside of data and in here you can find the payer object the payer has the organization id only populated for organization payers so yes technically it could be optional due to the nature of our app we're almost 100 certain that this will exist but yes technically it is optional by their type safety definition but you can see how it's typed organization id and outside of the payer i also know that there's a status which can be abandoned active canceled ended incomplete or past due so now that i have those two things what i'm going to do first is i'm going to grab the organization id from subscription my apologies this is just one subscription so subscription.payer dot organization ID and make sure to put a question mark here. In case there is no organization ID, I have no idea who purchased this and I don't know what to do with my database. So I have no choice but to return a response with an error missing organization ID because I don't know what organization should I update. What table should I create? I'm missing a reference to the buyer here. otherwise first things first one thing that still doesn't work is that even though i'm upgraded here in this account as you can clearly see in my plans and billing let me just refresh see if i go inside of my plans and billing obviously i am active on the pro plan but still if i try to invite someone it's not working so antonio at example.com is still throwing me an error so how do we fix that well we fix it by using the clerk backend api uh to well upgrade so right here what i'm gonna do is initialize const clerk client create clerk client secret key will be process dot environment clerk secret key. Now just double check that you have clerk secret key added here. Make sure there are no typos and also make sure that you have it inside of your convex environment variable clerk secret key. And you can also add a fallback like this. Perfect. Now that we have the clerk client, we can actually use that to upgrade this organization's ID settings and to increase the number of allowed members. So await, clear client, organizations, update organization using the organization ID, maximum allowed memberships. And in here, you can see that you can set a number to how many members you want to allow. So I'm going to set five members. So now this will no longer be an error because we will increase this from the limit of 1 to 5 because they are now paying members. And let's await context run mutation internal.system I didn't import internal so let's do internal from generated API system.subscriptions.upsert passing the organization ID and status to be subscription.status. And yes, let's go ahead and also modify this. So const new max allowed memberships. If subscription.status is equal to active, it will be five, otherwise it will be one. I think that's the correct logic, right? So if we receive this webhook event, subscription has been updated, and we see that subscription status is set to active, then we give them the new number. Hey, you can add up to five members. Otherwise, if this becomes canceled or past due, we fall back and give the user only access to one user. I think that makes sense. Great. So let me see what else that we need to do here. So yes, this is a switch case. So we need to properly finish it. So first things first, let's break. After that, let's add a default console log ignored clerk webhook event, event.type. So we know if any ignored events are happening here. And let's return new response null with a status of 200. This is important. You always have to return something at the end of your webhooks. Otherwise, they will be considered failures. Great. There we go. If we've done this correctly, this should now all work. But we can only test this in a new organization. So either switch to a new organization or just create a new one. And let's try again. So I cannot access my knowledge base. I cannot access voice assistant and I cannot access my widget customization. Not only that, but I cannot invite anyone. So antonio at example.com, send invitations, not working. Now I'm going to upgrade. And not only am I going to upgrade, I'm also going to keep a close track here in the activity or maybe logs, whatever shows first. So you can see, you can select this endpoint. And I think in here, you can track message attempts. So let's click subscribe. Let's click pay with test card. And let's see if it works. Maybe we forgot something. So immediately I'm upgraded. That's great. So I already know I can now see the knowledge base. I can see the voice assistant, right? But let's see, clerk. Let's go ahead and refresh here. And there we go. I have a succeeded subscription type in here. and you can see exactly that I had the payer I think somewhere here I can't find where it is there we go payer right here and I can find the organization ID for my payer perfect which should mean trying to find uh yes so we have to go inside of here inside of data subscriptions there we go i have a new subscription with the organization id and status active and i can think i can already check inside of organizations so it's this one my test the previous one right if i go inside of settings here there we go limited members are now five because we upgraded them so if i've done this correctly and i think i did if i go ahead and i add a random email here and click send invitations there we go invitations successfully sent which means that we officially allow this new member invites amazing amazing job so since this chapter is already an hour long and we've done the hardest part I'm going to end it here and then in the next chapter we're just going to do a very easy API check if we have the subscription table or not and using that we are going to either allow or block AI features and things like that but most of the things are finished and I'm pretty sure some of you already know exactly how you would wrap this up but don't worry we are going to do it together just in the next chapter and yes one thing I think exists now in the subscriptions if you actually find a subscription like this one for the test i think that not sure where but i saw that maybe you can now kind of like force you can kind of force the end of the subscription i'm just not sure how i'll try and research for the next chapter there we go so yes cancel subscription is one thing but end subscription kind of simulates if the user stopped paying and their active you know paid tier expires so if i click and subscription now what should happen is that first of all i should get an upgrade here let's see i'm not getting an upgrade okay that's not uh i'm not sure if i did something incorrectly or maybe that exact event doesn't fire a webhook but you can see this is upgraded immediately i am now blocked once again but yes i'm not sure if that fired an event let's see inside of where is it where are my webhooks i can't find them here they are the webhooks okay so subscription updated right here this is the new one that just happened so we have the data here we have the payer that's correct and the status was still initialized as active okay so i think this is just a bug uh with their uh development feature of course this is i I think this is just a development feature, which allows us to kind of end subscription now. I just think they set the incorrect status in their webhook event. I think 99% certain in production, when it ends naturally and not via this development button, the status will correctly change to ended or canceled. Right. So that's why this appears as a bug, but it's actually not. It works as intended. they just forgot to change that status i will try to double check this for you but i'm fairly certain that's what happened all right so uh yes we can kind of consider this api protection because it was mostly webhook but sure let's let's uh i'm satisfied with this for now and then in the next chapter we'll do the actual api protection amazing so 31 subscriptions let's go ahead and merge this. So I'm going to stage all of my changes. 31 subscriptions. Commit. I'm going to open a new branch. 31 subscriptions. And I'm going to publish the branch. And now let's open a pull request. And here we have the summary. We added billing page with new billing view and pricing table. We introduced premium feature overlay prompting upgrades for non-pro users. We enforced pro plan access for customization, files, and WAPI pages with graceful fallback. We updated account UI primary color for authentication components. So this is referring to the variable color we added to the clerk provider. And in here we have a sequence diagram explaining both how our premium feature overlay works as well as how our new clerk webhook works with in-depth explanation of how we actually verify the payload amazing we do have some comments here most of them are recommending to not render the files view or the whatever view within the premium feature overlay and yes the premium feature overlay can be used as simple as just you know a normal tag here but i think it's cool to kind of give the users a sneak peek of what's behind right so that's the only reason we are doing that but i'm not sure code rabbit understands what we're trying to do here so yes it is to prevent unnecessary fetching true yes but we will protect that in the back end in any way so uh great uh and in here i don't think it has the information about the newest organizations prop because it is technically in beta so that's why things for organizations doesn't exist but it does exist and we need it for our use case amazing amazing job so once we merge this let's go ahead and change back to main branch and click synchronize changes this way we will have up to date with our remote branch which we just merged into and as always i like to finish by confirming that I have that merged right here. Perfect. I believe that marks the end of this chapter. Amazing, amazing job. And see you in the next one. In this chapter, we're going to add some API improvements. This will include finally creating the refresh method for our contact session. We are then going to actually use that new refresh function to refresh the contact session when needed. which is whenever user is active, you will be able to decide for yourself what you think should count as a session refresh. Basically, it's a way so we don't delete the user's session because remember, user sessions have an expired time, right? So if you want to prolong that, if the user is active, if the user is sending messages or opening new conversations, it would make sense to prolong that expiration. So after 24 hours passes, they don't come back and all of their conversations are gone. And then we are also going to use our previously created subscriptions table to protect some API functions for pro only features. Let's start by creating the refresh contact session method and let's kind of refresh what and how this even works. so if you go inside of packages back end convex public contact sessions in here we have the create method so this is how we create contact sessions and in here you can see that this lasts for 24 hours in milliseconds right so let's go ahead and see what happens so we set it to last 24 hours right and after a certain time it expires what happens then well exactly what's happening to me right now even though I made I recorded some chapters yesterday you can see that now my session is completely gone well well that's not really the greatest experience because I had some chats open I sent some messages it would make sense that my session was prolonged and for at least another 24 hours because of that right so every time I send a new message if I am within the expired threshold, it should prolong. So it shouldn't just add 24, 48 up to infinity, of course, but it should have some kind of threshold. If I'm very close to expiring and if I'm still active, I should just add another 24 hours to this account just in case, right? So let's go ahead and just remind ourselves how that works. So go inside of this create method and just briefly modify this. Instead of this, let's go ahead and just use, I don't know, 10 seconds like this. So 10 times a thousand. So now if I go ahead and create 10, 10 at mail.com, you will see that this works just fine. I can refresh. I'm still here. I can still join here. Everything works. But after 10 seconds, if I wait, if I wait some more, some more, some more, I will eventually have to log in again. Right. My session just expired after 10 seconds. So for now, I want you to keep this at 10 seconds or maybe 30 seconds if it's easier for you to work with. So you have a visualization of how our session expires. And now let's go ahead and let's go inside of system and let's go inside of contact sessions. In here, we should have get1. And now we're going to add a new one, which we're going to call refresh. So export const refresh is going to be internal mutation, like so. It will have some arguments. Let me just close this. There we go. It will have arguments, contact session ID, which is a type of v.id contact sessions. we will have a handler asynchronous context and arguments. Whoops. Okay. And inside of here, what will happen is first things first, let's attempt to get the contact session by doing await context database get arguments contact session ID. If there is no contact session, Mm-hmm.\nLet's go ahead and throw new convex error from values with a code not found and a message contact session not found. Then let's check if it's already expired. So if contact session dot expires at is less than date dot now, there's no point in refreshing this. It already expired, right? So we'll just throw back an error here. And let's give this a code of expired, contact session, expired. Or maybe bad request would be the better type of code to throw here. And now if it is not expired, let's see how much time we have remaining. So time remaining here is contact session dot expires at minus date dot now. And now in order for us to decide, do we refresh this or not? We have to check if we are within some kind of threshold to refresh. So how about we make it so that the auto refresh threshold is four hours. so if we have four hours left and the user is still active we are going to refresh this and give them another 24 hours why four hours i don't know i just feel like that's a logical number you can put 10 hours you can put if they have 12 hours left you can put if they have 23 hours left if you want them to have the best experience but do keep in mind the longer you keep the session open the more it is susceptible to some kind of session attacks right so just for now let's also copy the session duration here exactly this one which we just modified so make sure the session duration is exactly the same as in your public file here and now let's finish this method here so once we have the time remaining let's check if time remaining is within the auto refresh threshold let's create the new expires at which will be date.now plus the session duration in milliseconds which I just copied here from here that's why it's important that it's the same. So we renew the entire expires at property and then we can do await context.database.patch arguments.contactSessionId expiresAt new expiresAt. And let's return contactSession expiresAt new expiresAt. And let's return contactSession otherwise. Great. So now we have a method that we can use to refresh the session. So let's try it out. I think the most logical way to use this refresh is whenever user sends a message right so in the public messages here let's find the create so if the user sends a new message after we confirm that this conversation exists after we confirm everything here is actually fine let's go ahead and do await context and I think that we have to do let me just see is this a mutation I already forgot. It's a mutation. So I think we can just do await context.runMutationInternal.System.ContactSessions.Refresh and passing the context session ID to be, let's see what do I actually have. I have arguments.contactSessionID. Perfect. arguments.contactSessionID. So I will add a little comment here. This refreshes the user's session if they are within the threshold. So the user at this point, since they're sending a message, is obviously active, right? But their session could expire soon. So if we detect that the user is active and their session is about to expire, if they have less than four hours left, we are going to refresh this for them. So let's try this out now. So I keep talking about this four hours, but this actually makes no sense because I just returned this to be 10 seconds. So let me just modify this too. Instead of four hours, I'm going to set this to be, I don't know, if we have five seconds left. so if the user is inactive for five seconds and then sends a message after five seconds i'm going to grant them a new expire date so let's check that out so first things first i'm going to go ahead and try this again so test one test one and mail.com and let's try one two three 4, 5, 6, 7, 8, 9, 10. I believe that now after I refresh, I have to create a new account. But this time, I'm going to try something else. I'm going to attempt to send a message. So 1, 2, 3, 4, five and let's send a message test six seven eight nine ten at this point if i refresh i should be logged out right except i am not i can still type here because i actively refresh my session every time that i send a message but if i don't do absolutely anything again for another 10 seconds I will have to enter my credentials again and start from scratch. This obviously feels very short in this example, but that's because we made this to be 10 seconds and this to be 5 seconds. In a real world example, we set this to 24 hours and we set the threshold to 4 hours. So if someone has 4 hours until we expire their session and we notice, hey, they are still sending messages, they are opening new conversations, and they only have four hours left, let's give them another 24 hours so they can relax. You can see that now when I refresh, I'm fairly certain, again, I have to create a new account. So we just proved that this entire thing works. Amazing. Now let's go inside of the contact sessions. Let's bring this back to 24 hours here. Let's copy it. Let's paste it here in the system. So it uses 24 hours here as well. and yes let's also fix the alpha threshold ms to be four hours so this is four hours in milliseconds and this is 24 hours in milliseconds let me find is there a way i can do inside of convex here let me go ahead and create constants constants.ds and I will attempt to just once write this instead of that new constants and export it so export session duration milliseconds and then let me try and maybe import it can I do that I think that I can and I will also do it here so now I don't have to type it into places I can just import it from constants right let me check everything seems to be working just fine here perfect so now if I am active within 20 within four hours of my expiry I will get another 24 hours and now it's up to you to decide you know what should be considered a refresh right so when the user sends a new message it makes perfect sense let's refresh the user session right let's run this internal mutation so you can do this for anything you want really uh i wouldn't really do it for get many maybe that's a bit too much but you could if you want to this way every time this contact session id fetches their messages it will be considered as hey, they're active. No reason to remove their session, right? Another place you can put this, for example, would be conversations in the public folder. Let's try and find this. So not to get one, let's try and find create mutation here. There we go. So after you confirm the session, yeah, already here, I think this is good enough. Let's go ahead and do this, right? So if the user creates a new conversation, why wouldn't we refresh their session, right? That makes sense. Just make sure you have imported the internal. And just like that, you have improved user's experience on the widget side because now they have longer lived sessions. Perfect. So we just completed this and we completed this. And now let's learn how to protect our functions for pro only features. so the most logical place to check this for is in when we send messages that's right in the public folder right here public messages and in here we actually have a to do implement subscription check so when should we actually trigger support agent response because this is how we decide whether we should trigger the response or not. Well, right now, it's only if the status of the conversation isn't unresolved. But now, let's enhance this by adding a new thing. const subscription await context run query internal.system.subscriptions get by organization ID and passing organization ID to be conversation.organizationID. So we have all of those things. Why are we doing run query and then calling internal here? Just a reminder, create is an action. It doesn't have access to the database. So now that we get the conversation from here, right, using another internal query and we confirm we have it it means we have an organization ID and then we can use our previously created internal system subscriptions get by organization id which very simply attempts to find the organization subscription using the index and returns a unique subscription and now what we're going to very simply do is improve this should trigger agent by also checking if subscription question mark dot status is equal to active and this is just single subscription like this and we can remove this to do now so we are only going to call support agent dot generate text as in respond to this user's query if the conversation status is unresolved and if this organization actually has a subscription because our AI responses are a premium feature for this SaaS. Of course, you can decide this logic for yourself, but I'm just using the most logical one. So let's try it out now, shouldn't we? I'm going to go ahead and create a completely new organization and I'm going to call it free. As in, this is my free organization, right? And then I'm just quickly going to go inside of the conversations here and I'm going to go inside of the clerk dashboard and I will copy the organization ID for that new free organization because it's important. Because once I have the organization ID, I can go to my localhost 3001 and in the URL, I can change my organization ID param to use that new organization ID. So in here, I obviously have to create a new account because this would be a brand new website that you would encounter this widget on. So I'm going to be Antonio, Antonio at example.com and I'm going to click continue. As you can see there are no options here because this is a brand new organization so no voice features nothing and I'm going to click start chat and sure this is the start message but this isn't AI right this is us hard coding a hello message. I think this is fine to have all the time right this is the bare minimum the chat box should greet you in some way now let's go ahead and do checking if ai will respond and as you can see no response that is because this organization is free right so no ai can respond but luckily the actual operator can respond hey there so automatically this turns into escalated because that's how we decide the flow will go. The flow is definitely more tailored towards pro users but you can still it works pretty fine with free users as well. So yes AI does not respond now because subscription is not active but let's see what happens if they decide to upgrade their free organization. Let's subscribe to $29 a month looks like we have some bug here but let's go ahead and just continue with the payment for now. There we go, payment was successful. And let's see what happens now. So the first thing that should happen is our Convex Dev should now have a new subscription table for our free organization. So instead of Echo Tutorial, if I go inside of my data here, instead of my subscriptions, I have two of them. And both of them are active. The previous one was active, it's kind of a bug actually, but the new one is active for real. And if I try this now, start chat and say, hey, can I talk to AI? Let's see. There we go. You are already talking to an AI. So it works because we just upgraded. So the internal subscription that was found was checked to have status active and the conversation status was unresolved, which is by default, which means should trigger agent turns to true. and now the AI responds. Amazing! And now you can use this exact logic here to protect whatever you want internally, right? For example, we do have some things we can protect. Inside of the conversations here, we have this feature. What do you mean, for example? We have this enhanced feature, right? And when we click on it, it spends our API tokens. I think those are the most important things to protect with an organization with a subscription check so let's see where do we do that it's in private is it in messages here it is enhanced response here so after we get the organization id let's do the following let's attempt to get the subscription using our internal system here and let's simply use the org id and then if there is no subscription question mark status my apologies if subscription question mark status is not equal to active let's throw new convex error here code bad request message missing or maybe i don't know uh missing subscription right whatever and let's try this again now so since in my ironically free organization i still have a premium let me refresh now and let me try this hiya enhance does it work it works hello how can i see today perfect but if i go ahead and start another organization test free again this one is now completely empty. Let's try this again. So I just have to go to the organizations. I have to go ahead and copy the organization ID. I have to go to my widget. I have to change the URL ID here. There we go. So new new mail.com. I'm a completely new user on a completely new website. Hi there. First of all, AI not responding. Great. Second of all, if the operator who is on their free tier attempts to use the enhance let's see what happens so again the operator wants to click enhance and we get an error bad request missing subscription and we can enhance this even further by showing a toast message but i think you get the gist right you can now reuse this internal query and simply call this exact thing anywhere you need, right? So for example, in the files in here, when we do add file action, you can do the same thing. After you confirm the organization ID, same thing. Check if they have a subscription because this is a premium query. So import internal from generated API and if the subscription isn't active or it doesn't exist throw an error missing subscription you can't upload a file right or if they want to well you should always allow them to delete things that's just you know being nice because otherwise they are kind of locked in and they have to pay so don't don't protect the delete file that's always a helpful thing to have but if they want to add new files, yeah, you should throw an error because that's a premium feature. Like you can only upload if you are on premium. And you can, of course, go through whatever you want here. Secrets. Should you allow them to up search secrets? Should you allow them to add new plugins, right? You can protect all of those things. You will decide it for yourself, but you'd use the exact same method like this, of course. I'm fairly satisfied with how it is right now. The only thing I want to do is just fix this so i'm gonna go inside of my conversation id view and somewhere in here i should have my enhance right here so on click handle enhance response and in here let's do toast from sonar dot error something went wrong there we go so just make sure you import toast from sonar so if you try again uh okay let's refresh maybe and try again something went wrong there we go so now we have a toast message as well perfect and let's see the knowledge base right even i can't even try it right because we protect it with the ui but if somehow they bypass this right they their API endpoint will still block them because we just added in the files here that they need to have a subscription one thing I would recommend you do is that you keep track of where you add the subscription check like I did in the files and in the messages here and just double check that it works for premium users right double check that there isn't a bug in this logical if clause or something like that right so in the plugins where would we add this i'm not really sure i wouldn't protect any of this you should be able to remove your plugin and you should be able to fetch them too even if you're not pro and for secrets same thing but we are using absurd so i'm not sure how smart it is to block the user from updating the secrets because this is pretty sensitive thing especially they want to remove it or something uh vapi these are only get methods so again i don't know if you want to you can protect this but fetching the api fetching the list of phone numbers and assistance will not occur any costs on your end uh so yeah and this is vapi is bring your own keys anyway so no costs on your site at all. Widget settings, same things, but then again, we use the upserts. I don't know how smart it is to protect that. Well, you can do it very easily by doing it in the else, right? So if it's inserting for the first time, this is where you would add the subscription check if you want to protect the widget settings from being updated without a subscription. Amazing, amazing job. So those are the API improvements I wanted us to do. You now know how to protect functions for pro-only features and you also know how to refresh the contact session. Let's go ahead and merge this. 32 API improvements. So I'm going to stage all of the changes. 32 API improvements. Let's commit. Let me go ahead and open a new branch. 32 API improvements and let's publish the branch. Now let's review the pull request. And here we have the summary. We added automatic extension of active contact sessions to prevent unexpected expiry during conversations Clear in error notification when response enhancement fails That the last thing we added with Sonner We enforced active subscription requirement for file uploads, AI response enhancement, and agent auto-replies. We centralized the session duration configuration for consistent session handling across the backend. and in here we have a few sequence diagrams this one is explaining our enhanced response and how it works by checking if the subscription is active or throwing an error if it is inactive this one right here explains how we refresh the contact session id every time we attempt to create a new conversation so we call internal system contact sessions refresh same thing if we attempt to create a new message. We call contact sessions dot refresh. And further, we also check if subscriptions get by organization ID exists in the very same API endpoint or function. And if it does, we trigger AI. Otherwise, we just save the message. Exactly what we did. And we did a pretty good job here no comments. Let's go ahead and merge this pull request and once we've merged it let's go ahead and head back to main and let's click synchronize changes and let's click okay. And once we've done that as always I like to confirm with my graph 32 API improvements amazing that marks the end of this chapter. Amazing job and see you in the next one. in this chapter we're going to go ahead and implement the ui for our widget integration this will be a screen where our operator dashboard will find their organization id as well as code snippets if they want to add the widget chat box to their respective technology HTML, React, Next.js or JavaScript. For now we're only going to be building the UI side of this so basically this screen and the model that opens when we click on each of these technologies and then in the next chapter I'm going to show you how we can actually develop the embed script. So let's go ahead and create the integrations view component. I'm going to go ahead inside of my apps web app dashboard and we should have integrations in here and the page.tsx let's keep that open and let's go inside of our modules and let's create a new folder integrations and let's go ahead and create UI and views inside of here let's create integrations view.tsx. Let's mark this as use client and let's export const integrations view. Let's go ahead and return a div integrations view. Now let's go back inside of that page and let's simply render the integrations view component. and now on your localhost 3000 when you click on integrations you should see the integrations view right here perfect now that we have that we can focus on developing only within that module so integrations view integrations view now we can actually copy this from the plugins view vapi view here let's go ahead and copy this div and this so basically the title and the subtitle and let's add it here. And I think we need to close one div. There we go. Just like this. And now let's just modify it slightly. So instead of Vapi plugin, this will say setup and integrations. And the description here can be something like choose the integration that's right for you. like that. There we go. Setup and integrations. Choose integration. That's right for you. There we go. And now in here, let's go ahead and let's render the current organization's ID. So the way we're going to do that is by going down here, opening a new div, and rendering a class name margin top 8 space y 6. And in here, let's go ahead and open a new div with a class name flex items center and gap 4. Let's add a label component. So make sure you've imported a label. Let's give this label a class name with 34 HTML4 website dash ID and passing organization ID text here and change the HTML4 to actually be organization ID. Let's be consistent. Below that, let's add a self-closing input component and make sure you've added an import for it. Let's go ahead and give this a class name of flex1, bg, background, font mono, and text small. Let me just go ahead and collapse these attributes so that they are easier to read. So besides the class name, I'm going to make this disabled. ID will be organization ID. It will be read only and the value can be one, two, three. there we go we now have organization id one two three now let's populate it with the actual organization id so make sure you have use client at the top of this page and you can extract organization from use organization from clerk next js and once you have that you can go inside of here and change the value to be organization dot id or an empty string and use a question mark here and let's just fix the typo let me see what I did wrong organization and now you should see your organization ID right here now let's add an ability to copy it in an easy way so I'm going to add a button import make sure you have added the button import let's go ahead and give this a class name gap2 on click for now just an empty arrow function and the size of small let's render copy icon here let's give it a class name size 4 and let's also give it a text copy and now you should see the button to copy perfect let's go ahead and quickly develop the copy button here. Const handle copy is going to be an asynchronous method. In the trial, let's await navigator clipboard write text organization question mark ID or an empty string toast.success copied to clipboard and import toast from Sonor. Otherwise, toast.error failed to copy to clipboard in case browser API fails. Now that we have this handle copy, we can just add it to the button on click. Let's refresh and let's try it out. There we go. Copied to clipboard and not sure where I can test this. Let me try and find a way to test it. There we go. When I paste, you can see exactly where it is. Perfect. So let's go back inside of the integrations here. And now let's go ahead and let's develop the integrations part. So outside of this div right here, let's add a separator component from workspace UI separator. So make sure you've added the import. Let's give this a class name my of 8. Let's add a div with a class name here space y 6. Inside a new div with a class name space y 1. Give this a label of integrations. And give it a class name of text large. below that a paragraph add the following code to your website to enable the chat box and let's give this a class name text muted foreground and text small and then in here let's go ahead and let's create a grid grid columns to gap four on medium devices grid columns for. So nothing should really show at the moment. That's because we need to create an array of our integrations. So instead of the integrations module, let's create constants.ds file like this. So inside of the integrations folder, let's export const integrations. and well you would add every integration you want to instruct your users on how to add the chat box to for example html and let's give it an icon of forward slash languages forward slash html5.svg we don't have this yet but we will have in a moment so now you can copy this a few times and in here you would add you know anything you want to support right so i'm adding react i'm adding next js even though these things don't actually make sense because all of this is just javascript right i'm just doing it to populate some space here uh it would make more sense if you did like um javascript and then you did shopify and then you did wordpress for example right so those actually different things because if you can do it in JavaScript you can do it in Next you can do it in React you can automatically do it in HTML So I just going to write the same script for all of these but I just trying to show you how this will look like So what you have to do now is you have to use the link on the screen to get to my public assets folder. And in here, go ahead and find the languages folder where I prepare the HTML5, JavaScript, Next.js, React, and similar. And once you have the languages downloaded, you should go ahead inside of your app, web, public, and create the languages folder in here and simply put HTML5, JavaScript, Next.js, and React inside. So inside of your public folder, create a new folder called languages. and then in your constants in the integrations it should match exactly the structure in your public folder. So once we have that done we can go back inside of the integrations view and we can go over the integrations which I just imported quickly from the constants. So integrations.map get the individual integration and in here I'm going to return a native HTML button element and I'm going to import an image from next image. So make sure you've added the image here. Let's give this an alt property of integration.title. Let's give it the height of 32, source integration.icon and width of 32. And now in here, you should start seeing HTML5, you should see React, Next.js and JavaScript and let's go ahead and give this button some class names so that's going to be flex items Center gap for rounded large border BG background padding for and hover BG accent there we go already looking better let's go ahead and give this a key of integration.id on click for now an empty arrow function and let's give it a type of button. After the image let's go ahead and create a paragraph here integration.title there we go html react nextjs and javascript perfect. So now what we have to do is that when we click on one of these a dialogue opens and it shows us the code snippet or instructions which you would do to add this to your project so let's go ahead down here and let's quickly develop the integrations dialogue in order to implement this we're going to have to import some things from the dialogue so let's go ahead and prepare that dialogue content description header and title all from workspace UI components dialogue. And let's go ahead and add some props here. So in here we're going to write the types. The types are going to be open, onOpenChange and snippet. So onOpenChange is a function which accepts the new value which is a Boolean and returns a void. And then let's extract all of those and snippet. Now, inside of here, let's go ahead and let's return a dialog. Let's give it on open change. Let's give it open of open. Let's create dialog content, dialog header, dialog title, integrate with your website. below that dialogue description follow these steps to add the chat box to your website outside of the dialogue header create a div with a class name space y6 another div with a class name space y2 and then a div with a class name rounded medium background accent padding of two and text small and let's simply write the first step copy the following code and now let's write our code snippet here using a div with a class name group and a relative then let's add a pre tag let's go ahead and give this a class name maximum width my apologies maximum height of 300 pixels, overflow X auto, like so, overflow Y auto, white space, pre-wrap, break, all, rounded medium, BG, foreground, padding to font mono, text secondary, and text small. And inside of here, you would render the snippet. Then add a native button, a Shotsian button element here and give it a class name, absolute, top, 4, right, 6, size, 6, opacity, 0, transition, opacity, group, dash, hover, opacity, 100. So that's why we put group in the parent div here. So when we hover over the code snippet, a button will appear. And this button will have an onclick for now an empty function with the size of icon and a variant of secondary. It's going to be a copy button for the snippet. So just add copy icon in here with a class name size three to make it a little bit smaller. and then in here let's go ahead and create we can just copy this right here space y2 like that and let's go ahead and give this a second step which will be add the code in your page and below this add a paragraph paste the chat box code above in your page you can add it in the HTML head section. And let's give this paragraph a class name text muted, foreground and text small. Great. Now let's go ahead and let's just quickly copy our handle copy method from here. Let's go ahead and add it inside of the integrations dialog here. But instead of organization ID, it will copy the snippet. and then you can use the handle copy in this button just like that perfect now we have our integration dialogue and now what we can do is we can render it so let's go ahead and wrap our entire integrations view here within a fragment like this let's go ahead and render the integrations dialog let's set on open to be set dialogue open which doesn't exist yet on open change this will actually be set dialog open the one above will be dialog open none of these exist yet and snippet will be selected snippet now let's go ahead and actually create all of those states so just above the organization use organization hook let's add dialog open and set the dialogue open using use state and And selected snippet and set selected snippet using use state like that. Now let's go ahead and let's develop const handle integration click. Integration ID will be a type of integration ID. Now let's go ahead and let's create the type integration ID. So we can actually do that quite easily by just revisiting our integration constants here. and then we'll just do export type integration ID, type of integrations, number, and then the ID field. And let's import integration ID now, just like that. So this way, we will only be able to call this function with a supported ID, next.js, React, HTML. so what I want to do now is go back inside of my constants and just simply prepare export const HTML script and for now let's just add a simple script snippet like this and let's copy this let's change this to React script let's change this to Next.js script let's change this to JavaScript script we are later going to modify the actual script that happens right so now what i want to do yeah let's also do one more thing here so the way this script integration will actually look like if we are if we want to be a bit more realistic it will have an attribute called data organization dash id the problem is since this is hard-coded in a string, we have to invent our own type of templating language. So let's add organization underscore ID here. So yes, each script snippet will have this attribute. So go ahead and add it everywhere here. So this is kind of a problem now, right? Because we need to somehow populate this hard-coded string with our organization ID that it's rendered right here and then display that to the user well we can do that quite easily instead of the integrations let's go ahead and create a new file called utils.ts let's go ahead and import HTML script type integration ID JavaScript script next J s script and\nreact script all from constants. And let's export const create script to be a function which accepts the integration ID, which is a type of integration ID, and organization ID, which is a type of string. And once we have those two, we can check if integration id is equal to html in that case let's return html underscore script dot replace and let's go ahead and add a forward slash open double square brackets like this curly brackets organization id forward slash g as in global and replace it with organization id. So what will happen is that this exact part will be replaced with one to three for example and that's how we are going to dynamically generate each organization id script. Great so now let's copy and paste this if the integration id is react let's change this to React script. Then let's go ahead and copy this for next JS, next JS script. And let's copy it for JavaScript. This will be JavaScript script. And all of them are the same. Otherwise just return an empty string. And since we are using the safe let's see integration ID is a type of string. Oh, so this is not really working as I thought it would work. I thought that this was an ennium. Looks like it's not an ennium. Okay, in that case, just be super careful. JavaScript, Next.js, React, HTML, and I'll try to find a way to make this an ennium, but just make sure that you're checking for the correct things here. Now let's go back inside of our handle integration script here. First things first, if there is no organization, let's go ahead and simply return, and we can even show a little error to the user. organization ID not found. I have no idea how this can happen except if it didn't load yet. But yeah, let's at least tell the user why we cannot generate the snippet because we need something to replace the organization ID template from. Now let's use the new create script from our utils. And let's pass in the integration ID, which is right here. And then let's pass organization.id. Set selected snippet, snippet, Set dialog open to true. Let's copy handle integration click and let's add it to the on click button in here and pass in the integration dot ID. Yes, that's the only thing it accepts. And there we go. You can see that now when I click on any of these it will open a script and the data organization ID here will be replaced with the actual organization ID that I have right here. So I can either copy it manually from here, which is now finally useful because in your widget, you no longer have to visit the clerk dashboard. You can just add organization ID, paste it here, and it will load into a working organization. Excellent. Amazing, amazing job. So that's the UI finished for this task. In the next chapter, we are going to work on actually developing this script right here that the users will be able to add to their plain new HTML landing page or React page or Next.js page and it will load this widget inside. So let's go ahead and see if we did what we intended to do. Correct and correct. One thing I dislike from here is that we don't have Ennium type safety so you can make easy typos here. As you can see this doesn't throw an error. I somehow thought that this would create an enum but it didn't i'm not sure how do i do that at the top of my mind right now i'll have to do it in the next chapter in research so this type actually is either a type of javascript or next js or react or html right so because this doesn't really make sense now yes i know it's a string right so yeah just make sure you didn't misspell the organization id here make sure that you are consistent with your id types here same here in the utils don't misspell them same thing with the organization id with the double curly brackets and everything should work uh fine if you did great so no logic developed here really we just developed nice ui so we don't have to worry about that anymore and we can uh head into the final chapters of this very long tutorial let's go ahead and merge this now. So I'm going to go ahead and I'm going to stage all of my changes. This is chapter 33 integrations UI. 33 integrations UI. Let's go ahead and commit. As always, I'm going to open a new branch, 33 integrations UI. I'm going to publish this branch. And now let's go ahead and review the pull request. And since this was a fairly simple PR, we can just read the summary and merge it. So we introduced an integrations page with a grid for HTML, React, Next.js and JavaScript integrations. View and copy your organization with one click and generate framework specific install snippets to do and copy them from a model dialogue. Exactly what we did. So yeah, there are some things I want to improve here, but we are mostly focused on just having the UI ready so that we can focus on developing the script. So good enough for me. Let's go ahead and merge this pull request now and once we've merged it let's go ahead inside of main and let's click synchronize changes so our main local branch is up to date with our remote one. As always I like to double check with the graph to confirm I have merged section 33. I believe that marks the end of this chapter. Amazing, amazing job and see you in the next one. In this chapter, we're going to be building our embed script which will allow our users to put our chat box within any application they own. In order to do that, we're going to create a third application within our monorepo called embed and this will not be a Next.js app. Instead, it will be a Vite, Vite, however you pronounce it, app. We're then going to create the actual embed.ts script, which will load our localhost 3001 widget app within an iframe, and it will give it some buttons such as close button, open button, and things like that, as well as some auto resize options. We're then going to build the build package JSON script, and we're going to use that to create a minimized embed script which we can then test and see if it works in a random empty html page so let's start by creating a new embed vt app so i'm going to go inside of my apps here and i'm going to create a new folder called embed within that folder i'm going to create a package dot json let's go ahead inside of here let's give this a name of embed like so let's go ahead and give it a version let's give it a type let's give it a private property and then let's create scripts now the scripts will be the following the most important one for now is going to be vit build this will be used to create the minimized script that will be inside of a dist folder and then we're going to copy that and we're going to store it somewhere where we can later load it in an empty html page so this is the only one that we're actually going to need for now so we can only leave that and now let's add some dev dependencies here. So types node version. What I like to do is I like to search for the versions that I'm using within my app here. So it's 20. So I'm going to put 20. In fact, I'm just going to copy from here. So I have the same version. Then I will have at workspace slint config. and I'm just going to use workspace and I'm going to use the little caret here like so. Then I'm going to add TypeScript config. In here I'm just going to put an asterisk. For my S-Lint version, let me search again what's being used here. so 9.20.1 so I'm going to add this TypeScript I think I have multiple versions of TypeScript yes, somewhere I use latest and somewhere I use 5.7.3 I like being specific so I'm just going to copy this and add it here and finally the Vite version which will be again with this little caret here 5.0.8 of course if you're watching this far into the future and you want to be up to date you should probably look up the newest versions but yes if you're just following the tutorial feel free to use the exact versions that I am using and once you have that you can go ahead and just run pnpm install in the root of your app here. And I think that that should update the package log file with all these new packages. There we go. So immediately pnpm lock apps embed. Perfect. Now that we have that and we have the node modules in here, let's go ahead and let's create the, well, step by step. Let's start with the eslint config, right? So eslint.config.js is going to import base config from at workspace slint config forward slash base dot js and let export default and simply spread base config here just like that then what i want to do next is create the ts config so we have the typescript rule tsconfig.json this config.json will be an object which is going to extend And our workspace is TypeScript config base dot JSON. Then let's add the compiler options. And the compiler options will have the following lib. We're then going to have module and the rest of the properties. So target module resolution, no emit, skip lib check, true. then let's add include so what we're going to include for now will be embed.ts which is the script we will create white.config.ts or vit then we're going to have vit-environment.d.ts and let's actually target every ts file inside of here and then every folder which has a TS file inside like this and I think let me just restart TypeScript server so this is still happening here I'm not too sure why but I'm just going to leave it like this for now now let's go ahead and let's create the VIT config So inside of embed, let's create vt.config.ts. Let's import define config from vt. Let's import resolve from path. And let's export default define config. Inside of here, let's add build. Why build? Because in package.json, that's the only script we've added. so we have to define it in the vid config let's open lib object entry resolve dear name embed.ts so this will be our entry this is the file which we are going to write and the output will be something else so name of this script will be echo widget the file name can just be widget formats. Now in here, I don't know too much about this. This is one I found that works. If you know more about this formats, feel free to use the more optimized one. For the rollup options, let's set output, extend, true. And for the server, well, we don't need anything more actually at this point. so this is enough now let's go ahead and let's create the Vite environment file so Vite-environment.d.ts let's go ahead and add this at the top I think this is required if you want Vite environment files to work so this is three forward slashes here so be mindful and then let's go ahead and create the interface import meta env read only vt widget url it's a type of string and let's add interface import meta import meta environment type so that's our vt env.d.ts now that we have this let's go ahead and let's start creating the embed script. I think we can already kind of try this. If I go instead of apps embed and if I create embed.ts here and if I just do console log hello world like that or maybe if I do const Antonio hello const something world and if I then attempt to join the two so console.log Antonio plus something I'm trying to purposely make this script a little bit longer because the build script is supposed to minimize it I'm not sure if this is enough to trigger that minimization so the way we build is this So maybe this format or maybe this rollup options is what is deciding how minified this will be. Since we have package.json build, we can build it in two ways. We can just run turbo build in the root of our app. But since we don't want to wait for all other apps, we can just go ahead instead of our apps embed. And let's do turbo build here. There we go. Embed build. And now instead of our dist folder, you should have a minified version. so you should also now have this new .turbo here which is cache and inside of here there we go you can see a minified version of our script here so inside of embed.ds we will be able to develop a readable script right and inside of here we will have a minified script which is you know shorter it is not readable but it is something you will host inside of some cdn or somewhere or maybe just in your public folder, which is what we are going to do. And then you're going to reference this file so you don't have to reference this not obfuscated longer file, right? This one needs to be longer because it needs to be readable so you understand what you're developing, right? Perfect. So that's what we just wanted to do right now. That's an amazing first step. So our Vite build is officially working. Now it's time to actually develop this script. What I want to start with is with the config file. So config.ts. Let's export const embed config. And now inside of here, let's create the widget URL, which will be import.meta.environment.vit widget URL. And I'm pretty sure that this type safety comes from this very cool vit environment.d.ts. That's how it recognized it and let's also give it a fallback so if it cannot find this let's go ahead and simply use 3001 why 3001 well because if you go ahead in the root of your application here and if you do turbo dev let's go ahead and focus on the widget dev you will see that the widget is running on 3001 so make sure that you use whatever your widget is running on because that's what we are building the script for so that is the page that will load within an iframe and if you're wondering why don't we have embed here very simply because we never added the development script so instead of the apps embed package json we only have build script so turbo repo knows that it cannot run the development script here that's why it's not being run here so great now that we have this result let's go ahead and let's add default position which I'm going to add bottom dash right as constant so that's going to be the default position now that we have this let's go ahead and let's add the icons so instead of embed let's add icons.ts since both of these icons which we are going to use two of them the chat bubble icon and the close icon are going to be svg files the best way to do this is to simply copy it from my assets folder you can see the link on the screen so head inside of the embed folder here and head inside of the icons and in here simply copy that file and paste it here and you should have the chat bubble icon and you should have the close icon now it's time to build the actual embed script so let's prepare that instead of apps embed let's go ahead and go inside of embed.ts. So we've already started doing something here. Now, here's the thing. I know that some of you, I think most of you, love to see me write everything from scratch. And I love doing that too. In fact, we've been doing that together for the past 20 hours, I think. But in this particular script, you're going to see it's not really worth it to write it from scratch. I built this entire script, iteratively, I'm sorry, I just butchered the word, basically, through a lot of trial and error, through all of refactoring and fine tuning the implementation details. And if I walk you through each line, it would take a significant chunk of time, which we could better spend on actually developing the embed app, right? So let's be efficient with our time here. I have added the entire embed.ts script inside of the public GitHub repo. And I think that the moment you open it, you're going to understand what I'm talking about here. You can see there isn't much of a learning value here, right? But I will explain in chunks exactly what's happening here. So let's go ahead and just copy this script here, embed.ts, and let's paste it inside. And now I'm going to go ahead and explain exactly what's going on here. So first things first, we are importing the config from our .config and we are importing the icons from our .icons. So in the first chunk here, what is this? Why are we defining the function like that? You can see that in here we define the function within its own parenthesis and then down here we execute that function. Why are we doing that? Well, the answer lies inside of our vit.config.ts in our format right here. So this is IIFE or immediately invoked function expression. So that's what this is. This is an immediately invoked function expression right here. And what that means is that it creates a private scope. So we don't pollute the global namespace, which means we can initialize our main variables, the iframe, the container, the button, and the state tracking without overriding any global variables. So yes, that's what we do here. As you can see, we set the variable for iframe, for container, for the button, and some is open state tracking. The clever part here is how we grab the configuration. So in here, we have to obtain the data organization ID. But how do we know for which organization is this script going to be used for? Well remember inside of our web I think that the name of our app it is we have a module called integrations And in here, we have constants. And in here, we have already talked about the way that we are going to pass this organization ID to our scripts. So this is how we're going to do that. We're going to load this embed.ts within a file like this. and we're going to pass the data organization ID with 1, 2, 3, 4 inside, right? And then in here, once we obtain the current script, we're going to get that attribute from the script, from this element right here. So we are obtaining ourselves and we are getting the data organization ID. One thing that we are also obtaining is the data position. That is if the user, for whatever reason, wants to move from bottom right to maybe bottom left. But if we don't have this, we simply fall back to the default position, which is inside of this config bottom right, which is the most popular one, right? In case this fails, we also attempt to find this using a different selector, right? So just some additional logic here. Great. So what do we do next? What is this init? What is this render? So that is DOM initialization and rendering. Basically, we have to check if the DOM, the document, is ready before rendering. This is crucial for avoiding any errors. And then the render function itself creates three main elements. A floating action button with smooth hover effects, right? So that's what's happening right here. Echo widget button. We create the button. We give it an inner HTML of this chat bubble icon, which is just an SVG of a small bubble icon. And then we give it some positioning in the bottom right corner or the bottom left corner, right? And we give it some colors which match our app as well as some other styling. We then give it some listeners here, which will then transform or it will trigger some functions such as toggle widget. And one thing that we're doing here, as you can see, is writing CSS in line so that no external CSS is needed here. And we also need the container built the same way that the button is built. So what does the container do? The container is what's holding our iframe. And within our iframe is where we are going to load our widget. what's important for the iframe here is that we allow the microphone clipboard read and clipboard write because we have a vapi so we can listen to a microphone and clipboard read and clipboard write so we can copy the phone number if you remember because we have that function too great so in here we do have some additional scripts such as build a widget url and things like that. So let's go ahead down here to that script actually and let's see what's going on here. So in here we are finally using that organization ID that we extracted from above. We extracted it from this right from data organization ID and now that we have that organization ID we build the iframe URL and the iframe URL is localhost 3001 and then a param organization ID. So the config here is set to localhost 3001. So in here, what we are doing basically is we are returning localhost 3001 and we append a prop called organization ID and we set it to a variable, one, two, three. And that is exactly what we've been doing this entire tutorial whenever we want to test the widget. So we are now just doing that via a script. great and then we have some hide and show animations here as you can see some additional functions to toggle the widget to hide the widget maybe some additional animations and one thing at the bottom that we do here is a public API so that you can destroy or manipulate or reinitialize this entire widget from the inspect element or from if any developer wants to access it in their own little way. So we are kind of making this an even better script than it is. Great. So again, this entire script is available here. I hope they explained it in a nice way. As you can see, it's quite a lengthy script and I don't think we would learn much if I went line by line here because this isn't our usual code which we do which is JSX right this is just pure JavaScript it's not particularly pretty code I know and I felt it's better explained like this in chunks rather than you watching me do it every single line here great let's go ahead and try and build it now so I'm gonna go ahead back inside of my apps embed and let's do turbo build And let's see, there we go. Now, inside of your dist, you will see a much longer version of this. There we go. But still, a minified version, which you can now use and test out in some other app. But here's one thing I'm sure you don't actually know. So right now, my dist here, as you can see, uses the widget URL to localhost 3001. Now, sure, because that's currently where our widget app is running. But what if you deploy the widget app and it has its own domain, right? Antonio-widget.com. Well, we actually have a solution for that as well. I just completely forgot to tell you about it. So the way you can do that is by running vit-vidget-url-https-antonio-vidget or maybe, for example, covidantonio.com and pnpm build. And now when you build it, you can see that the widget URL, So again, go inside of dist-vidget.js. You can see that the widget URL is codewithantonio.com. So right in here is where organization ID 123 will be appended. So that's how you can change the URL of where your widget application is hosted. So for us, PNP and build is enough because I have hardcoded it to 3001 because I know that in the root of our app, when I do Turbo Dev, the widget app is running on 3001. So I know that that's what I have to load within my iframe. Now, in order to test this, let's go ahead and let's add one more HTML file here. So again, it makes no sense to build this from scratch. this is just a literal testing suite of the embed.ts script so this already works you can kind of already finish the tutorial here I'm just giving you a cooler way to test this embed script so let's go ahead and inside of the embed let's create a demo.html hdml like so let's go ahead inside of our embed folder demo.html so this is just a normal html file and it's a testing suite for the embed.ds file now what you have to modify here of course is the organization id so why am i hard coding an organization id here because this is not for customers this is for you internally so it's easier for you to test out this embed script which we just went over that's why so yes let's go ahead and fix this now so i'm gonna go ahead and well let's go ahead and do this first how about this we first go inside of package.json here in the embed and now let's add a dev script. So dev and let's add vit port 3002. Why 3002? Well, simply because in other package JSON, like in web, we have already reserved port 3000. In the widget app, we have reserved 3001. So the only one we have left is 3000. I mean, we have more left, but incrementally it's 3002. But that's not enough. what we have to do now is also go inside of v.config.ts and inside of here let's go ahead and add server port 3002 and open forward slash demo.html just like this and now in the root of your app you should be able to run TurboDev and you should finally see the embed app using Vite. Now in here, as you can see, we have this button and you can see unable to verify organization. Exactly. We have a problem. That's because we are not passing the proper organization ID here. So let's go ahead and fix that. So head inside of your one of your apps here. I mean, one of your organizations. If you wanted to choose a premium one, choose a premium one. Maybe that will be easier for you. So you can test multiple features. Let's see, is this a premium one? It's not a premium one. Which one of these is a premium one? The free one? Yes, the free one is the premium one. Okay, so I'm going to go inside of integrations and I will copy my organization's organization ID. We can do that from here now and I want you to go inside of your embed and go inside of the config first and let's quickly just extend the config here by also adding a default organization ID and just add your default organization ID here and then go inside of demo.html and change this in here too. So all instances of some hard-coded organization ID, just change it, right? Even though I think this one is just presentational. It doesn't matter. I'm fairly sure. Okay, no. Inside of here, data organization ID also changed this. I think I left this because I couldn't find a nice way to modify HTML directly. The scripts are easy to modify, but for these ones, you have to change them manually So find the data organization ID change it here and just one place above too and then go back and save your echo widget demo let refresh now oh this one is much faster okay and there we go look at this our app is working i can start chat from here hey there how are you perfect look at this we have a working application on some random vt app we've been able to integrate our chat box and in here let's go ahead and set of the conversations. There we go. We can chat with our customer in real time. All thanks to Convex. Hey there. Look how amazing it is. I'm so impressed by this because this is like a completely different framework using Vite running our app here. And what this test suite is for is so you can kind of test this, you know, let's initialize the widget again. If you want to test this on this side or if you want to destroy it if you want to show it initialize it right it's just for that it's just for testing so you have it so it's easier for you to test the widget but we are not exactly finished yet because there is just one more thing that we have to try to confirm that this works as intended and that would be running turbo not turbo build I mean yes but just inside of apps embed go ahead and run turbo build so you have the latest and the newest widget.iife.js and then go ahead uh in the root of your app and run turbo dev okay and now here's what you would have to do so uh inside of your apps embed instead of this you now have this minified script which you're supposed to host somewhere. So where should you host it? Absolutely anywhere. It doesn't matter where you host it because this script, it doesn't matter if this script is private or not. You can host it on, I don't know, Cloudflare. You can host it on BunnyCDN. You can host it on GitHub. I don't know, wherever you want. But since we don't have that setup yet, let's just pretend that inside of widget our public folder is the host so let's go ahead and do that let's go ahead and add widget and let's rename this to js you can do that so just inside of public widget.js and then what i want you to do is go inside of embed and go ahead and create a landing.html as in this is some random landing page HTML. Let's go ahead and just set up this randomly. And in here, what we would do now, oh yes, I completely forgot. We now have to kind of modify the following. So we should now go inside of apps, web, modules, integrations, constants, and we have to modify at this script now, right? So this part is okay, but the actual source of the script should be different. So what source am I talking about? Well, in here, it should be source HTTP localhost localhost 3001 forward slash widget dot JS, right? So that's why I told you this can be anything, right wherever you want to hold that minified file if this is some cdn.cloudflare.com sure it will be like this it doesn't matter but just for now since this is the only place this is the only idea i have right just putting it inside of widgets public folders because the public folder technically serves like a cdn i guess i'm just using it imagine you got this random script from the internet that's what i'm trying to explain to you you can have this anywhere anywhere right you don't have to have an xjs app running to to host this app right but you will have to know where you have it and you will have to know uh uh right where you store it like this and then go ahead and add it to all these examples and then you can do the full integration process for real now right click inside of integration here and you will see a proper script let me just go ahead and do that so integrations and let's go ahead i'm adding it to my html here it is right this is the exact one i have let's go ahead and copy this and then i would go let's imagine i'm some random developer who is trying to integrate echo instead of my project here i have my little landing page.html and I would just go ahead and add this script here. So I'm heading to localhost 3001 widget.js and I already have my data organization id prepended for me. So now I'm just going to go ahead and open this landing.html file. And there we go. So if you're wondering how did I open it? I literally just went in my file explorer, right click, open, and it will automatically open with Chrome or whatever browser you're using. And there we go. Test, testmail.com. Let's go ahead and continue. Start chat. Hey there. It works. So someone just successfully added our website, our little script to their landing page. That's what this was all about, right? That's how this is going to work. So what's important here? The most important part for you to remember will come now in the next chapter, which is deployment. And that is you will have to know when you build this embed minified script, where your widget URL will be. So what is the widget URL? The widget URL is not embed script URL. So don't confuse just because I'm using localhost 3001 here, and I'm also using it inside of my config, inside of my constants in the integrations, right? This is just a coincidence. This can be anything, right? It doesn't matter where you host widget.js, But it does matter where the widget.js script thinks that your widget app is being hosted. So when we deploy to Vercel now, we will have two different apps. The web app running on one domain and the widget app running on another domain. The widget app will never be directly visited through URL. It should only be visited through an iframe. so if you manage to get this working amazing the project is like 99.9 finished all that's left is to deploy seriously amazing amazing job super long tutorial and i'm extremely proud of what you did also if this is a problem for you you can turn this off this is just for development so these are dev tools so don't worry your users won't actually see this little thing here I think this is so cool, right? And everything just works. Amazing, amazing job. So let's go ahead and merge this and in the next chapter, let's deploy it. So I'm just going to go ahead, stage all of these changes, 34 embed script. Let's commit. I'm going to open a new branch, 34 embed script, and I'm going to click publish branch. Now let's review the pull request. And here we have the summary by CodeRabbit. We introduced an embeddable widget with a floating button and iframe panel. We also support configurable position, bottom right or bottom left, animated show and hide, as well as some post message driven, resize and close. we auto initialize via script tag and we expose a global api for init show hide and destroy we added a demo page with controls and example usage and we also added a simple landing page where we pretended to be a user embedding our chat widget amazing amazing job that is exactly what we did since this was a fairly simple pull request and we copied the snippet from GitHub, it doesn't make sense to really do any review changes here because we went really in depth with chapter by chapter review. So I'm going to go ahead and merge this and then we can go ahead instead of our main branch and we can synchronize changes to make sure everything is up to date. So in the next chapter, what we are going to do, make sure that 34 is the last one you've merged is we're finally going to deploy this and learn how to do this once again with deployed scripts so i believe this marks the end of this chapter we created a bit app script build script and we tested the minimized embed script as well as pushed to github amazing job and see you in the next one in this chapter working to finish this project by deploying. We're going to individually deploy our web app, our widget app, and then we're going to test the embed script again from this new live application. So before you do anything, what I recommend you do is double check that you are on your main branch, double check that you have the recent changes merged in, and then head in the root of your app and simply run turbo build. It will probably take some time. Mine is now very fast because it's cached. But you should see four successful builds. If not, you will probably see what the errors are. There will most likely be some lint or type errors. So you can easily fix those. Simply go inside of the files, which the error tells you. If you're struggling and you cannot fix them, but you just want to deploy, you can go ahead and search for this exact line in Google and add inside of your search turn off or like pass right just if you want to turn it off because you can turn it off but i would highly recommend that you actually try your best and fix these errors so now head to vercel.com you can use the link on the screen and click add new project And in here, as you can see, you have your project and it is recognized as Turbo, right? So it knows that there are three projects inside. You can see how Vercel is smart when it comes to this. So now we have to choose which apps we're going to create.\nadd I believe so let me go ahead and see what we're doing so the first thing I want to do is echo web so let me go ahead and try and change this to not use embed instead use web so go ahead and select web and continue and that will automatically as you can see change this to next.js apps web project name next 15 echo web now let's go ahead and set the environment variables here. So inside of apps, web, let's go instead of dot environment dot local and let's copy all of these and let's paste them inside of our app. Just like that. Now for the build and output settings, I'm pretty sure this is all good. I don't think we have to modify anything here. So yeah, make sure that you have selected. Again, you can click edit here and then inside of your next, inside of your GitHub repository name, apps select web. That's the first one we're doing. Call this your project name. For me, it's next15echo. For you, it can be echo, right? It doesn't matter. And give it a suffix of web so you know what this is. Paste the environment files inside from your environment.local from the web app and click deploy. Now I'm going to pause the screen and we're going to see if this works or not and here we go so looks like this went well if i click here now i should be seeing the login screen for my app let's see there we go sign in to echo let's go ahead and log in and let's see if we can see the operator dashboard right so this should be the web application that we just deployed. Let's see, it's loading. And here we go. Oh, yes, I forgot to fix this. Don't worry, this won't be any special screen. This will be a redirect. And there we go. I can see my conversations. Let's see if this works. Test. It works. Everything works just fine. I can resolve it. I can do everything. Knowledge base works. Widget customization. all of it is working just fine as well as the payments everything's working perfect so now let's go ahead and let's deploy the widgets you can see how easy it is I can just down here I can do that so I'm not sure how you would usually do it but yes if you're still on this screen down here find apps widget and click deploy like this so let's see what will happen now I think it needs to load or maybe there we go so it selects a new project basically usually how you would do it I think is just by clicking deploy again I think it's as easy as that you can just click new project again and then select again this and this time change it again not embed, go inside of apps and select widget next 15 echo widget or in your case whatever this part is simply add dash widget here next js preset and let's open environment variables here so this time inside of widget just one perfect let's add it here and the build i think everything is fine let go ahead and let hit deploy so again i going to pause the screen and let see if this is successful or not And here we go another app successfully deployed And there we go Of course there an error here because you should never really visit this through a new URL, right? But what is the problem now? Our app is deployed, but does this integration now really work? Well, no, because this is still pointing to localhost 3001. That's a problem. It shouldn't be like that. So what should be this URL? Let's go ahead and fix that now. Now that this is deployed, you now have your dashboard. So click continue to dashboard and make sure you're looking at the echo widget one. And don't look at the deployment URL. Just look at the domains. That's the one you're interested in. This is our domain. So now head back inside of your app here and go inside of your apps, web, and inside of your modules, integrations, constants, and simply finally modify this localhost thing with this. There we go. Like that. but then again I don't want you to miss the point this is again not really important the only reason we have to change this is because that's the place where we are hosting our widget.js script if you would actually host this somewhere on some CDN then it wouldn't matter you wouldn't even have to change this because it would always come from a CDN right some CDN.js.com and then something, right? But in our case, we are hosting it here. So naturally we need it here, right? So two things we have to change. First, go ahead and save this simply because this is now the new public URL of our place where we host widget.js. That's the first thing we have to change. All right, the second thing we have to change is inside of apps, web, inside of app, we have dashboard and we have this page.tsx, which is doing absolutely nothing. So what we can do now, you don't even have to delete this page, you can if you want to, but you can go inside of the nextconfig.mjs within appsweb and inside of the nextconfig here, go ahead and add asynchronous redirects and return source forward slash destination forward slash conversations permanent false like this. So now whenever users visit the root page, they will get redirected to conversations. And that's how we fix that issue. But now we have another problem. Our current script inside of embed dist, as you can see, thinks that the widget is hosted here. Well, that's not true. That doesn't work, right? Because if you try and let me just go ahead and find our landing page, so landing.html, let me go ahead and open it. There we go. You can see that right now something's not working in this landing page. In fact I have an error in the widget script So something obviously broken Why is it failing It failing because we are trying to load widget Well first of all because we are trying to load widget.js from here, which is not where it's hosted anymore. So this is now hosted here in Next15 Echo widget. So if you save this file and refresh, then it works, but then it fails again. Why? Well, we just fixed the issue of where the script is hosted. But the widget.js script, which is this exact one in the dist file, thinks that the widget app is on localhost 3000 and month. That's false. It's not. It's here. So what we have to do is we have to rebuild the widget. So let's go ahead inside of apps embed. Yes, inside of apps embed. and let's just do turbo build. But before that, let's go ahead and let's do, I already forgot how you do it. Just a second. Vite widget URL. There we go. That's the one we need. And change that to be HTTPS next 15 echo widget for sale.app because that's where the widget is. So this part is super important, right? This is not where you host the widget script. This is where the widget application is. This is what the iframe should load. So this part, what URL is bundled within the widget minified script is completely different than where this script is hosted. I have a feeling it's confusing you that this is the same URL. That's just an accident. This can be any URL in the world, right? And same thing for this integration constants. Any URL in the world does not matter. But what does matter is the widget script. Where does it think that the widget app is being hosted? This is where, inside of this domain right here. So copy it, prepare it here, widget URL right here, pnpn build inside of apps embed there we go now we have a new script inside of this right here there we go widget url is now successfully modified for production so i will copy this widget file again i will go inside of my widget app and inside of the public folder because that's where i'm hosting my widget file for now later you will change this somewhere smarter, right? Because this is super confusing that it's the same URL. So I'm going to delete the old one and I'm going to paste the new one, make sure the new one has the proper widget URL and simply rename this to widget.js. Perfect. Now that we have this couple of things modified here, what you can do is in the root of your app oh or yeah no need to open a new branch for this entire thing you can just go ahead and stage all changes let's call this so this is what the 35 deployment sure 35 deployment commit you can immediately push on your main branch because when you push on the main branch or if you merge a pull request into the main branch this will trigger various redeployments on Vercel As you can see it queued right Because I have some other ones deployments let see There we go So now it rebuilding my Echo Web. Why is it rebuilding Echo Web? Well, because we just modified Echo Web. We modified the next config to redirect so it doesn't show that empty page and instead it just shows the conversation. So make sure you don't have a typo here or it will lead to a 404. And after that, it will rebuild this because we added a new public script there. So this is why you shouldn't host the widget script here, because every time you update the widget script, instead of just uploading a new one to your CDN, you have to rebuild the entire widget app, that makes no sense. But just for development purposes, I'm showing you how you can do that. So I'm going to pause, we're going to test this, and then we're going to test again our pretend landing page in the embed here. This is where we are pretending that we are some user trying to integrate our app. And here we have both apps fully deployed, or should I say redeployed. So I'm going to go ahead and go into their respective projects now. So let me go ahead and click in next echo web. Click on the domain. Always use this URL. Don't use this specific branch URL. So let me go ahead and open this. Okay, this works perfectly fine. Let's, and yeah, if you've noticed, I'm not sure if you noticed, but now we no longer have that root page. It just redirects to the conversations. So that's what we modified. Perfect. So that works just fine. Now we have integrations to check. So HTML uses the new host next 15 echo widget for sale. Again, not the perfect solution, but it works. Yes. And now we can copy this and you can put it in your landing page. But before we do that, let's check. And you can actually copy this source here and try to access it yourself. And in here you can see our minified script, right? That's where we are hosting it. And in here, you can check that this script thinks that the widget app is hosted in the correct URL, which means that now let's pretend again, I am a brand new customer and I click HTML, I copy this and I go inside of my HTML landing page. and in my body here, I add the script with my organization ID and the proper URL here. So then I'm going to go ahead here and let's go ahead and open this and everything works. Hello, let's call this production, productionmail.com. Hi there, is this working in production? and let's finally check in our conversations there we go we are in production amazing amazing job you just finished a super super long super complex tutorial i think this is my most in-depth tutorial yet the closest to an actual b2b real world sass thank you so so much for following Remember to like, share and subscribe and leave a comment if you liked it. And see you in the next tutorial. Amazing, amazing job.",
  "transcript_chars": 461050,
  "transcript_filled_at": "2026-06-06T15:52:46.047165+00:00",
  "transcript_filled_by": "tk-bulk-groq-retry-20260606"
}