Build a storefront chatbot with OpenAI
Topics covered: Shopify connections, AI + vector embeddings, HTTP routes
Time to build: ~30 minutes
Large Language Model (LLM) APIs allow developers to build apps that can understand and generate text. We can use OpenAI's APIs to build a chatbot that can understand a shopper's question and respond with product recommendations.
In this tutorial, you will learn how to:
- set up a Shopify and OpenAI connection in Gadget
- add custom data models and fields
- use OpenAI's text embedding API to generate vector embeddings for product descriptions
- use OpenAI's chat API to generate and stream a response to a shopper's question
- use a Shopify theme app extension to embed the chatbot into a storefront
Prerequisites
To get the most out of this tutorial, you will need:
- A Shopify Partners account
- A development store
- The Shopify CLI installed on your local machine
- Gadget's CLI: ggt, installed locally
Step 1: Create a Gadget app and connect to Shopify
Your first step will be to set up a Gadget project and connect to a Shopify store via the Shopify connection. Create a new Gadget application at gadget.new and select the Shopify app template.
Now we will set up a custom Shopify application in the Partners dashboard.
- Go to the Shopify Partner dashboard
- Click on the link to the Apps page
Both the Shopify store Admin and the Shopify Partner Dashboard have an Apps section. Ensure that you are on the Shopify Partner Dashboard before continuing.
- Click the Create App button
- Click the Create app manually button and enter a name for your Shopify app
- Click on Settings in the side nav bar
- Click on Plugins in the modal that opens
- Select Shopify from the list of plugins and connections
- Copy the Client ID and Client secret from your newly created Shopify app and paste the values into the Gadget Connections page
- Click Connect on the Gadget Connections page to move to scope and model selection
Now we get to select what Shopify scopes we give our application access to, while also picking what Shopify data models we want to import into our Gadget app.
- Select the scopes and models listed below and click Confirm to connect to the custom Shopify app
- Enable the Products Read API scope, and select the underlying Product and Product media models that we want to import into Gadget
Now we want to connect our Gadget app to our custom app in the Partner dashboard.
- In your Shopify app in the Partner dashboard, click on Configuration in the side nav bar so you can edit the App URL and Allowed redirection URL(s) fields
- Copy the App URL and Allowed redirection URL from the Gadget Connections page and paste them into your custom Shopify App
Now we need to install our Shopify app on a store.
- Go back to the Shopify Partner dashboard
- Click on Apps to go to the Apps page again
- Click on your custom app
- Click on Select store
- Click on the store we want to use to develop our app
- You may be prompted about Store transfer being disabled. This is okay, click Install anyway
- Click Install app to install your Gadget app on your Shopify store
If you are getting a permissions denied error when installing your app, try logging in to the Shopify store Admin!
You will be redirected to an embedded admin app that has been generated for you. The code for this app template can be found in web/routes/index.jsx
.
Now you can install your app on a store from the Partners dashboard. Do not sync data yet! You're going to add some code to generate vector embeddings for your products before the sync is run.
Step 2: Set up OpenAI connection
Now that you are connected to Shopify, you can also set up the OpenAI connection that will be used to fetch embeddings for product descriptions. Gadget provides OpenAI credits for testing while building your app, so you don't need a personal OpenAI API key to get started.
- Click on Settings in the nav bar
- Click on Plugins
- Click on the OpenAI connection
- Select the Use Gadget's API keys option in the modal that appears OR enter your own OpenAI API key
- Click Add connection at the bottom of the page
Your OpenAI connection is now ready to be used!
Step 3: Add data models for chat responses
Now we need a new data model to capture the chat responses from OpenAI, and the products recommended to shoppers. We need to use a has many through relationship to relate the chat response to the recommended products.
- Click + next to the
api/models
folder and add a newchatLog
model - Add the following fields to
chatlog
:
Field name | Field type |
---|---|
response | string |
recommendedProducts | has many through |
Now you need to define the recommendedProducts
relationship.
- Set the
sibling
toshopifyProduct
and thejoinModel
torecommendedProduct
, this will create a newrecommendedProduct
model - Name the field in
recommendedProduct
that relates to thechatLog
model,chatLog
- Name the field in
recommendedProduct
that relates to theshopifyProduct
model,product
- Name the field in the
shopifyProduct
model that relates to therecommendedProduct
model,chatRecommendations
Step 4: Add vector field to shopifyProduct
model
Before you add code to create the embeddings from product descriptions, you need a place to store the generated embeddings. You can add a vector field to the shopifyProduct model to store the embeddings.
The vector field types store a vector, or array, of floats. It is useful for storing vector embeddings and will allow you to perform vector operations like cosine similarity, which helps you find the most similar products to a given chat message.
To add a vector field to the shopifyProduct
model:
- Go to
api/models/shopifyProduct/schema
and add the following field:
Field name | Field type |
---|---|
descriptionEmbedding | vector |
Now you are set up to store embeddings for products! The next step is adding code to generate these embeddings.
Step 5: Write code effect to create vector embedding
Now you can add some code to create vector embeddings for all products in your store. You will want to run this code when Shopify fires a products/create
or products/update
webhook. To do this, you will create a code effect that runs when a Shopify Product is created or updated.
- Go to the
api/models/shopifyProduct/actions
folder and add a newcreateEmbedding.js
action - Paste the following code into
api/models/shopifyProduct/createEmbedding.js
:
1import { applyParams, save, ActionOptions } from "gadget-server";2import { preventCrossShopDataAccess } from "gadget-server/shopify";34export const run: ActionRun = async ({5 params,6 record,7 logger,8 api,9 connections,10}) => {11 applyParams(params, record);12 await preventCrossShopDataAccess(params, record);13 await save(record);14};1516export const onSuccess: ActionOnSuccess = async ({17 params,18 record,19 logger,20 api,21 connections,22}) => {23 try {24 // get an embedding for the product title + description using the OpenAI connection25 const response = await connections.openai.embeddings.create({26 input: `${record.title}: ${record.body}`,27 model: "text-embedding-ada-002",28 });29 const embedding = response.data[0].embedding;3031 // write to the Gadget Logs32 logger.info({ id: record.id }, "got product embedding");3334 // use the internal API to store vector embedding in Gadget database, on shopifyProduct model35 await api.internal.shopifyProduct.update(record.id, {36 shopifyProduct: { descriptionEmbedding: embedding },37 });38 } catch (error) {39 logger.error({ error }, "error creating embedding");40 }41};4243export const options: ActionOptions = {44 actionType: "update",45 triggers: {46 api: true,47 },48};
1import { applyParams, save, ActionOptions } from "gadget-server";2import { preventCrossShopDataAccess } from "gadget-server/shopify";34export const run: ActionRun = async ({5 params,6 record,7 logger,8 api,9 connections,10}) => {11 applyParams(params, record);12 await preventCrossShopDataAccess(params, record);13 await save(record);14};1516export const onSuccess: ActionOnSuccess = async ({17 params,18 record,19 logger,20 api,21 connections,22}) => {23 try {24 // get an embedding for the product title + description using the OpenAI connection25 const response = await connections.openai.embeddings.create({26 input: `${record.title}: ${record.body}`,27 model: "text-embedding-ada-002",28 });29 const embedding = response.data[0].embedding;3031 // write to the Gadget Logs32 logger.info({ id: record.id }, "got product embedding");3334 // use the internal API to store vector embedding in Gadget database, on shopifyProduct model35 await api.internal.shopifyProduct.update(record.id, {36 shopifyProduct: { descriptionEmbedding: embedding },37 });38 } catch (error) {39 logger.error({ error }, "error creating embedding");40 }41};4243export const options: ActionOptions = {44 actionType: "update",45 triggers: {46 api: true,47 },48};
In this snippet:
- the OpenAI connection is accessed through
connections.openai
and theembeddings.create()
API is called - the internal API is used in the
onSuccess
function to update the shopifyProduct model and set thedescriptionEmbedding
field
The internal API needs to be used because the shopifyProduct
model does not have a Gadget API trigger on this action by default. You can read more about the internal API in the Gadget docs.
Now enqueue this action from your api/models/shopifyProduct/actions/create.js
and api/models/shopifyProduct/actions/update.js
actions:
- Open
api/models/shopifyProduct/actions/create.js
and paste the following code:
1import {2 applyParams,3 preventCrossShopDataAccess,4 save,5 ActionOptions,6} from "gadget-server";78export const run: ActionRun = async ({ params, record }) => {9 applyParams(params, record);10 await preventCrossShopDataAccess(params, record);11 await save(record);12};1314export const onSuccess: ActionOnSuccess = async ({ record }) => {15 // only run if the product does not have an embedding, or if the title or body have changed16 if (!record.descriptionEmbedding && record.body && record.title) {17 await api.enqueue(api.shopifyProduct.createEmbedding, { id: record.id });18 }19};2021export const options: ActionOptions = {22 actionType: "create",23};
1import {2 applyParams,3 preventCrossShopDataAccess,4 save,5 ActionOptions,6} from "gadget-server";78export const run: ActionRun = async ({ params, record }) => {9 applyParams(params, record);10 await preventCrossShopDataAccess(params, record);11 await save(record);12};1314export const onSuccess: ActionOnSuccess = async ({ record }) => {15 // only run if the product does not have an embedding, or if the title or body have changed16 if (!record.descriptionEmbedding && record.body && record.title) {17 await api.enqueue(api.shopifyProduct.createEmbedding, { id: record.id });18 }19};2021export const options: ActionOptions = {22 actionType: "create",23};
- Open
api/models/shopifyProduct/actions/update.js
and paste the following code:
1import {2 applyParams,3 preventCrossShopDataAccess,4 save,5 ActionOptions,6} from "gadget-server";78export const run: ActionRun = async ({ params, record }) => {9 applyParams(params, record);10 await preventCrossShopDataAccess(params, record);11 await save(record);12};1314export const onSuccess: ActionOnSuccess = async ({ record }) => {15 if (16 !record.descriptionEmbedding &&17 record.body &&18 record.title &&19 record.changed("body") &&20 record.changed("title")21 ) {22 await api.enqueue(api.shopifyProduct.createEmbedding, { id: record.id });23 }24};2526export const options: ActionOptions = {27 actionType: "update",28};
1import {2 applyParams,3 preventCrossShopDataAccess,4 save,5 ActionOptions,6} from "gadget-server";78export const run: ActionRun = async ({ params, record }) => {9 applyParams(params, record);10 await preventCrossShopDataAccess(params, record);11 await save(record);12};1314export const onSuccess: ActionOnSuccess = async ({ record }) => {15 if (16 !record.descriptionEmbedding &&17 record.body &&18 record.title &&19 record.changed("body") &&20 record.changed("title")21 ) {22 await api.enqueue(api.shopifyProduct.createEmbedding, { id: record.id });23 }24};2526export const options: ActionOptions = {27 actionType: "update",28};
Gadget's background actions are used so you can manage OpenAI's rate limits using the built-in concurrency control and retries!
Now vector embeddings will be generated when a new product is created, or an existing product is updated.
Generate embeddings for existing products
Now that the code is in place to generate vector embeddings for products, you can sync existing Shopify products into your Gadget app's database. To do this:
- Click on Settings in the nav bar
- Click on the Plugins page
- Select the Shopify connection
- Click on Installs for the connected Development app
- Click on the Sync button for the store you want to sync products from
Product and product media data will be synced from Shopify to your Gadget app's database. The code effect you added will run for each product and generate a vector embedding for the product. You can see these vector embeddings by going to the Data page for the shopifyProduct
model. The vector embeddings will be stored in the descriptionEmbedding
field.
Step 6: Add /chat
HTTP route
We will use an HTTP route to handle incoming chat messages from the storefront. The route will take a message from the shopper, use cosine similarity to determine what products to recommend, and stream a response from OpenAI back to the client.
- Hover over the
api
folder and right-click on it to create a folder namedroutes
- Click + next to
api/routes
create a new file - Name the file
POST-chat.js
Your app now has a new HTTP route that will be triggered when a POST request is made to /chat
. You can add code to this file to handle incoming chat messages.
- Paste the following code in
api/routes/POST-chat.js
:
1import { RouteContext, RouteHandler } from "gadget-server";2import { openAIResponseStream } from "gadget-server/ai";34const route: RouteHandler<{5 Body: { message: string };6}> = async ({ request, reply, api, logger, connections }) => {7 // get input from shopper8 const { message } = request.body;910 // embed the incoming message from the user11 const embeddingResponse = await connections.openai.embeddings.create({12 input: message,13 model: "text-embedding-ada-002",14 });1516 // find similar product descriptions17 const products = await api.shopifyProduct.findMany({18 sort: {19 descriptionEmbedding: {20 cosineSimilarityTo: embeddingResponse.data[0].embedding,21 },22 },23 first: 2,24 filter: {25 status: {26 equals: "active",27 },28 },29 select: {30 id: true,31 title: true,32 body: true,33 handle: true,34 shop: {35 domain: true,36 },37 featuredMedia: {38 file: {39 image: true,40 },41 },42 },43 });4445 // capture products in Gadget's Logs46 logger.info(47 { products, message: request.body.message },48 "found products most similar to user input"49 );5051 const prompt = `You are a helpful shopping assistant trying to match customers with the right product. You will be given a question from a customer and some JSON objects with the id, title, handle, and description (body) of products available for sale that roughly match the customer's question, as well as the store domain. Respond in HTML markup, with an anchor tag at the end with images that link to the product pages and <br /> tags between your text response and product recommendations. The anchor should be of the format: <a href={"https://" + {domain} + "/products/" + {handle}} target="_blank">{title}<img style={border: "1px black solid"} width="200px" src={product.featuredMedia.file.image.url} /></a> but with the domain, handle, and title replaced with passed-in variables. If you have recommended products, end your response with "Click on a product to learn more!" If you are unsure or if the question seems unrelated to shopping, say "Sorry, I don't know how to help with that", and include some suggestions for better questions to ask. Here are the json products you can use to generate a response: ${JSON.stringify(52 products53 )}`;5455 // send prompt and similar products to OpenAI to generate a response56 // using GPT-4 Turbo model57 const chatResponse = await connections.openai.chat.completions.create({58 model: "gpt-4-1106-preview",59 messages: [60 {61 role: "system",62 content: prompt,63 },64 { role: "user", content: message },65 ],66 stream: true,67 });6869 // function fired after the steam is finished70 const onComplete = (content: string) => {71 // store the response from OpenAI, and the products that were recommended72 const recommendedProducts = products.map((product) => ({73 create: {74 product: {75 _link: product.id,76 },77 },78 }));79 void api.internal.chatLog.create({80 response: content,81 recommendedProducts,82 });83 };8485 await reply.send(openAIResponseStream(chatResponse, { onComplete }));86};8788export default route;
1import { RouteContext, RouteHandler } from "gadget-server";2import { openAIResponseStream } from "gadget-server/ai";34const route: RouteHandler<{5 Body: { message: string };6}> = async ({ request, reply, api, logger, connections }) => {7 // get input from shopper8 const { message } = request.body;910 // embed the incoming message from the user11 const embeddingResponse = await connections.openai.embeddings.create({12 input: message,13 model: "text-embedding-ada-002",14 });1516 // find similar product descriptions17 const products = await api.shopifyProduct.findMany({18 sort: {19 descriptionEmbedding: {20 cosineSimilarityTo: embeddingResponse.data[0].embedding,21 },22 },23 first: 2,24 filter: {25 status: {26 equals: "active",27 },28 },29 select: {30 id: true,31 title: true,32 body: true,33 handle: true,34 shop: {35 domain: true,36 },37 featuredMedia: {38 file: {39 image: true,40 },41 },42 },43 });4445 // capture products in Gadget's Logs46 logger.info(47 { products, message: request.body.message },48 "found products most similar to user input"49 );5051 const prompt = `You are a helpful shopping assistant trying to match customers with the right product. You will be given a question from a customer and some JSON objects with the id, title, handle, and description (body) of products available for sale that roughly match the customer's question, as well as the store domain. Respond in HTML markup, with an anchor tag at the end with images that link to the product pages and <br /> tags between your text response and product recommendations. The anchor should be of the format: <a href={"https://" + {domain} + "/products/" + {handle}} target="_blank">{title}<img style={border: "1px black solid"} width="200px" src={product.featuredMedia.file.image.url} /></a> but with the domain, handle, and title replaced with passed-in variables. If you have recommended products, end your response with "Click on a product to learn more!" If you are unsure or if the question seems unrelated to shopping, say "Sorry, I don't know how to help with that", and include some suggestions for better questions to ask. Here are the json products you can use to generate a response: ${JSON.stringify(52 products53 )}`;5455 // send prompt and similar products to OpenAI to generate a response56 // using GPT-4 Turbo model57 const chatResponse = await connections.openai.chat.completions.create({58 model: "gpt-4-1106-preview",59 messages: [60 {61 role: "system",62 content: prompt,63 },64 { role: "user", content: message },65 ],66 stream: true,67 });6869 // function fired after the steam is finished70 const onComplete = (content: string) => {71 // store the response from OpenAI, and the products that were recommended72 const recommendedProducts = products.map((product) => ({73 create: {74 product: {75 _link: product.id,76 },77 },78 }));79 void api.internal.chatLog.create({80 response: content,81 recommendedProducts,82 });83 };8485 await reply.send(openAIResponseStream(chatResponse, { onComplete }));86};8788export default route;
This code will:
- create a vector embedding from the shopper's message using the OpenAI connection using
connections.openai.embeddings.create()
- use cosine similarity to find the 2 most similar products to the shopper's message using the Gadget API:
1const products = await api.shopifyProduct.findMany({2 sort: {3 descriptionEmbedding: {4 // cosine similarity to the embedding of the shopper's message5 cosineSimilarityTo: embeddingResponse.data[0].embedding,6 },7 },8 first: 2,9 select: {10 // ...11 },12});
1const products = await api.shopifyProduct.findMany({2 sort: {3 descriptionEmbedding: {4 // cosine similarity to the embedding of the shopper's message5 cosineSimilarityTo: embeddingResponse.data[0].embedding,6 },7 },8 first: 2,9 select: {10 // ...11 },12});
- use the OpenAI chat API to generate a response using the
gpt-4-1106-preview
model
1const chatResponse = await connections.openai.chat.completions.create({2 model: "gpt-4-1106-preview",3 messages: [4 {5 role: "system",6 content: prompt,7 },8 { role: "user", content: message },9 ],10 stream: true,11});
1const chatResponse = await connections.openai.chat.completions.create({2 model: "gpt-4-1106-preview",3 messages: [4 {5 role: "system",6 content: prompt,7 },8 { role: "user", content: message },9 ],10 stream: true,11});
- stream the response back to the client and save the records to the database using
reply.send(openAIResponseStream(chatResponse, { onComplete }))
onComplete
is a callback function that is called after the stream is finished
Set up CORS handling
Before we can call our /chat
route from a theme extension, we need to enable CORS handling. To do this we will use the @fastify/cors
plugin.
- Open the Gadget command palette using P or Ctrl P
- Enter
>
in the palette to change to command-mode - Run the following command to install the
@fastify/cors
plugin:
Run in the Gadget command paletteyarn add @fastify/[email protected]
Once the plugin is installed, you can add it to your app's configuration:
- Add a new file in the
api/routes
folder called+scope.js
- Paste the following code into
api/routes/+scope.js
:
1import { Server } from "gadget-server";2import cors from "@fastify/cors";34/**5 * Route plugin for *6 *7 * @param { Server } server - server instance to customize, with customizations scoped to descendant paths8 *9 * @see {@link https://www.fastify.dev/docs/latest/Reference/Server}10 */11export default async function (server: Server) {12 await server.register(cors, {13 // allow requests from any domain14 origin: true,15 });16}
1import { Server } from "gadget-server";2import cors from "@fastify/cors";34/**5 * Route plugin for *6 *7 * @param { Server } server - server instance to customize, with customizations scoped to descendant paths8 *9 * @see {@link https://www.fastify.dev/docs/latest/Reference/Server}10 */11export default async function (server: Server) {12 await server.register(cors, {13 // allow requests from any domain14 origin: true,15 });16}
This will allow requests from ANY domain! For your actual, production app, you probably want to set the origin
option to a specific domain - the domain of your store. Read more about CORS in Gadget in our documentation.
Your route is now complete! Now all that is needed is a frontend app that allows shoppers to ask a question and displays the response along with product recommendations.
Step 7: Use Shopify theme app extension to embed chatbot
We make use of an app embed block for our storefront chatbot theme extension. This means that our extension works with any Shopify theme, both vintage and Online Store 2.0 themes.
- Use git to clone the Shopify CLI app
You can also build Shopify extensions inside your Gadget project directory so you can manage your entire app with a single Git repo. This tutorial skips this step due to all the assets that need to be copied for the chatbot!
cd
to the cloned directory- Update the direct script tag in
extensions/theme-extension/blocks/chatbot.liquid
to include your app's script tag URL
Your script tag needs --development added to your app-specific subdomain when working on your Development environment:
<script src="https://example-app--development.gadget.appapi/client/web.min.js" defer="defer"></script>
- Run
shopify app dev
to start your app and connect to your existing Partners app and development store
You should now be set up and ready to add the chatbot theme app extension to the storefront theme.
- Navigate to your storefront theme editor and click on App embeds in the left sidebar
- Enable your app - the chatbot should appear in your storefront preview
- Click Save in the top right corner of the page
Theme extensions allow you to create custom blocks that merchants can place in the storefront theme editor. You can read more about theme extensions in the Shopify docs. This chatbot extension is an app embed block, so it works with both vintage and Online Store 2.0 themes.
In this theme there are 3 main files:
blocks/chatbot.liquid
- the main file that renders the chatbotassets/chatbot.js
- handles the chatbot logic and calls your Gadget app's APIassets/chatbot.css
- the CSS file that styles the chatbot
Your chatbot should now be available in your storefront - try it out!
Next steps
You now have a chatbot that can respond to shopper questions with product recommendations! You can continue to build on this app by:
- customizing the embedded React frontend, found in the
frontend
folder, to give merchants installation instructions - editing the prompt in
api/routes/POST-chat.js
to customize the chatbot's response - change the look, feel, and merchant customization options for the chatbot by editing the theme extension files
- maintain full chat context by passing
messages
back and forth between the client and server- also maintain chat context between browser sessions and/or windows by storing the full chat context in the database
Have questions about the tutorial? Join Gadget's developer Discord to ask Gadget employees and join the Gadget developer community!
Want to learn more about building AI apps in Gadget? Check out our building AI apps documentation.