DEV Community

Cover image for 💡Build a Local Notion with an AI Agent Assistant 🤖
Arindam Majumder Subscriber for CopilotKit

Posted on

💡Build a Local Notion with an AI Agent Assistant 🤖

TL;DR

In this article, we will build a Next.js application that integrates with the Notion API to manage a user database and enables interaction with this data using CopilotKit.

By the end of the article, you will learn how to:

  • Set up server actions to fetch a user’s Notion database.
  • Implement a chat feature using CopilotKit to query the database.
  • Use CopilotKit to edit a user’s Notion database directly within the chat.

Plus, we’ll explore ways to achieve type safety in environment variables for your Next.js project. 👀

GIF


What is CopilotKit

CopilotKit is the leading open-source framework for integrating production-ready AI-powered copilots into your applications. It provides a feature-rich SDK that supports various AI copilot use cases, including context awareness, copilot actions, and generative UIs.

copilotkit homepage

This means you can focus on defining your copilot's role, rather than getting bogged down in the technicalities of building one from scratch or dealing with complex integrations.

Check out CopilotKit's GitHub ⭐️

Setting Up the Project 🛠️

First, Initialize a Next.js project with the following command:

ℹ️ You can use any package manager of your choice. Here, I’ll use npm.

npx create-next-app@latest copilotkit-with-notion-api --typescript --tailwind --eslint --app --use-npm

Enter fullscreen mode Exit fullscreen mode

Navigate into the project directory:

cd copilotkit-with-notion-api

Enter fullscreen mode Exit fullscreen mode

Installing Dependencies

We’ll need several dependencies. Run this command to install all the dependencies required for our project:

npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @notionhq/client @t3-oss/env-nextjs openai zod

Enter fullscreen mode Exit fullscreen mode

For an improved coding experience, install the following development dependencies:

npm i --save-dev prettier-plugin-organize-imports prettier-plugin-package prettier-plugin-tailwindcss

Enter fullscreen mode Exit fullscreen mode

Configuring Prettier

Now that Prettier is installed, let’s configure it to match our preferences. Create a .prettierrc file in the project root with the following settings:

// 👇 .prettierrc
{
    "arrowParens": "avoid",
    "printWidth": 80,
    "semi": false,
    "singleQuote": true,
    "jsxSingleQuote": true,
    "trailingComma": "all",
    "proseWrap": "always",
    "tabWidth": 2,
    "plugins": [
        "prettier-plugin-tailwindcss",
        "prettier-plugin-organize-imports",
        "prettier-plugin-package"
    ]
}

Enter fullscreen mode Exit fullscreen mode

Feel free to adjust these rules to suit your preferences.

Setting Up Shadcn UI

For a collection of ready-to-use UI components, we’ll use shadcn/ui. Initialize it with the default settings by running:

npx shadcn@latest init -d

Enter fullscreen mode Exit fullscreen mode

Adding Type-Safe Environment Variables

To manage environment variables, we’ll go beyond the typical .env setup and implement type safety using TypeScript. This ensures that the app won’t run without all required variables properly defined.

We’ll use the @t3-oss/env-nextjs library and the zod schema validation library for this purpose.

Create a new file lib/env.ts with the following code:

// 👇 lib/env.ts

import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  /*
   * Serverside Environment variables, not available on the client.
   * Will throw if you access these variables on the client.
   */
  server: {
    NOTION_SECRET_API_KEY: z.string().min(1),
    NOTION_DB_ID: z.string().min(1),
    OPENAI_API_KEY: z.string().min(1),
  },
  /*
   * Environment variables available on the client (and server).
   *
   * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
   */
  client: {},
  /*
   * Due to how Next.js bundles environment variables on Edge and Client,
   * we need to manually destructure them to make sure all are included in bundle.
   *
   * 💡 You'll get type errors if not all variables from `server` & `client` are included here.
   */
  runtimeEnv: {
    NOTION_SECRET_API_KEY: process.env.NOTION_SECRET_API_KEY,
    NOTION_DB_ID: process.env.NOTION_DB_ID,
    OPENAI_API_KEY: process.env.OPENAI_API_KEY,
  },
})

Enter fullscreen mode Exit fullscreen mode

This approach validates environment variables at runtime. If any required variable is missing or invalid, the app will simply fail to start.

As you might have guessed, all these env variables must be defined in the .env file. This includes the Notion API keys and, most importantly, the OpenAI API key.

For now, simply populate the .env file with your OpenAI API Key:

OPENAI_API_KEY=<YOUR-OPENAI-API-KEY>

Enter fullscreen mode Exit fullscreen mode

Setting up Notion 📔

To use the Notion API, we first need to set up a Notion integration.

Create a New Integration

Visit notion.so/my-integrations and create a new integration. Provide a name, select the workspace, and note the Internal Integration Secret.

Image

Update the integration’s capabilities to include Update and Insert permissions.

Image

Add the secret key to your .env file as:

NOTION_SECRET_API_KEY=<YOUR-SECRET-HERE>

Enter fullscreen mode Exit fullscreen mode

Set up a Database

In Notion, create a new database or use an existing one. For this tutorial, here’s a sample database with three columns: name, link, and dueDate.

Image

You can customize these columns to match your specific requirements.

Find your Database ID

To get the Notion Database ID, check the URL of your database. The ID is the string between the Notion domain name and the ?v= query parameter.

Add this ID to your .env file:

NOTION_DB_ID=<YOUR-DB-ID-HERE>

Enter fullscreen mode Exit fullscreen mode

Assign Integration to the Database

Go to your database, click the menu button in the top-right corner, and assign the integration you created earlier.

Image

With this setup complete, your application is ready to access and manage your Notion database using the Notion API. ✨


Setting up CopilotKit 🤖

So far so good, Now, let’s integrate CopilotKit — the soul of our application that enables interaction with the Notion database.

Define Constants

Start by creating a constants.ts file in the lib/ directory to centralize constants related to your database structure and API endpoint:

// 👇 lib/constants.ts

export const NOTION_DB_PROPERTY_LINK = 'link'
export const NOTION_DB_PROPERTY_NAME = 'name'
export const NOTION_DB_PROPERTY_DUE_DATE = 'dueDate'

export const COPILOTKIT_API_ENDPOINT = '/api/copilotkit'

Enter fullscreen mode Exit fullscreen mode

Update the database column names to match your setup. The COPILOTKIT_API_ENDPOINT constant defines the endpoint for CopilotKit requests.

Create an API Route

Next, create a route.ts file inside the /app/api/copilotkit directory:

// 👇 app/api/copilotkit/route.ts

import { COPILOTKIT_API_ENDPOINT } from '@/lib/constants'
import { env } from '@/lib/env'
import {
  CopilotRuntime,
  OpenAIAdapter,
  copilotRuntimeNextJSAppRouterEndpoint,
} from '@copilotkit/runtime'
import { NextRequest } from 'next/server'
import OpenAI from 'openai'

const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY })
// Here, we are using GPT-3.5-turbo OpenAI model instead of the default `gpt-4o`
const serviceAdapter = new OpenAIAdapter({ openai, model: 'gpt-3.5-turbo' })
const runtime = new CopilotRuntime()

export const POST = async (req: NextRequest) => {
  const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
    runtime,
    serviceAdapter,
    endpoint: COPILOTKIT_API_ENDPOINT,
  })

  return handleRequest(req)
}

Enter fullscreen mode Exit fullscreen mode

This file sets up a POST route for handling CopilotKit requests. It uses the OpenAI GPT-3.5-turbo model and defines a runtime environment for processing requests.

The POST function listens for requests, processes them using a custom runtime and service adapter, and then sends the response back through the CopilotKit endpoint for handling AI-generated responses.

Add CopilotKit Provider

To integrate CopilotKit into your application, wrap your app in the CopilotKit provider. Also, include the pre-built Copilot popup for instant UI functionality.

Update layout.tsx as follows:

// 👇 app/layout.tsx

import { COPILOTKIT_API_ENDPOINT } from '@/lib/constants'
import { CopilotKit } from '@copilotkit/react-core'
import { CopilotPopup } from '@copilotkit/react-ui'
import '@copilotkit/react-ui/styles.css'

// ...Rest of the code

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang='en'>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <CopilotKit runtimeUrl={COPILOTKIT_API_ENDPOINT}>
          <main>{children}</main>
          <CopilotPopup instructions='You are assisting the user as best as you can. Answer the best way possible given the user notion database information.' />
        </CopilotKit>
      </body>
    </html>
  )
}

Enter fullscreen mode Exit fullscreen mode

First, we are simply importing the required modules and the custom styles that will be needed to make the Copilot popup window look good.

We wrap our application with the <CopilotKit /> provider and pass the runtimeUrl to our COPILOTKIT_API_ENDPOINT constant, which is the /api/copilotkit endpoint.

By now, you should already see a small chat popup in the bottom right corner of your application. It should also already look good out of the box.

This is all we need to set up CopilotKit in our application. 🥂 Now, all that’s left is to provide the context of our application so that it can read our data and guide us in real time.


Work on the Implementation 👷‍♂️

Now, that all the before works is done, lit's time to implement the core functionality of our application.

We'll start by defining the data types and progressively build the functionality to fetch, and manipulate data from the Notion database.

Defining Types

Create a file types/notion.ts in the types/ directory to define the structure of our Notion database data:

// 👇 types/notion.ts

import {
  NOTION_DB_PROPERTY_DUE_DATE,
  NOTION_DB_PROPERTY_LINK,
  NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'

export type TResponse = {
  success: boolean
  error: Error | null
}

export type TRow = {
  id: string
  properties: {
    [NOTION_DB_PROPERTY_NAME]: {
      id: string
      title: { text: { content: string } }[]
    }
    [NOTION_DB_PROPERTY_LINK]: { id: string; url: string }
    [NOTION_DB_PROPERTY_DUE_DATE]: {
      id: string
      type: 'date'
      date?: { start: string; end: string }
    }
  }
}

export type TRowDetails = {
  id: string
  [NOTION_DB_PROPERTY_NAME]: string
  [NOTION_DB_PROPERTY_LINK]: string
  [NOTION_DB_PROPERTY_DUE_DATE]: {
    start: string
    end: string
  }
}

Enter fullscreen mode Exit fullscreen mode

We firstly have the TResponse type which we will use in our server actions to define the return type of a function. Then we have the TRow and the TRowDetails type which basically holds the type definition of the data in each row of the notion database.

The TRow type is defined to match the returned data from the notion API. The TRowDetails type is the custom type which I have made to hold only the data I plan to show in each row in the UI.

💡 Adjust the properties in these types according to your Notion database structure.

Fetching Data from Notion

Create lib/actions.ts to define server-side actions for interacting with the Notion database:

// 👇 lib/actions.ts

'use server'

import { env } from '@/lib/env'
import { TResponse } from '@/types/notion'
import { Client } from '@notionhq/client'
import { QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints'

const notion = new Client({
  auth: env.NOTION_SECRET_API_KEY,
})

export const fetchNotionDB = async (): Promise<
  QueryDatabaseResponse | TResponse
> => {
  try {
    const dbQuery = await notion.databases.query({
      database_id: env.NOTION_DB_ID,
    })

    return dbQuery
  } catch (error) {
    return {
      success: false,
      error: error as Error,
    } as TResponse
  }
}

Enter fullscreen mode Exit fullscreen mode

The fetchNotionDB function is marked as a server action ('use server') to ensure it only runs on the server, regardless of where it’s called.

Firstly, we are creating a instance of notion client by passing our database id. Then in the fetchNotionDB function, we are querying our notion database with the notion API and returning back the response.

Displaying Data

Now, let’s update the app/page.tsx file to fetch and render the Notion database data:

// 👇 app/page.tsx

import { NotionTable } from '@/components/notion-table'
import { fetchNotionDB } from '@/lib/actions'
import {
  NOTION_DB_PROPERTY_DUE_DATE,
  NOTION_DB_PROPERTY_LINK,
  NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'
import { isErrorResponse } from '@/lib/utils'
import { TRow } from '@/types/notion'

export default async function Home() {
  const response = await fetchNotionDB()

  if (isErrorResponse(response)) {
    return (
      <div className='mt-10 text-center text-rose-500'>
        Failed to fetch data. Please try again later.
      </div>
    )
  }

  const dbRows = response.results.map(row => ({
    id: row.id,
    // @ts-expect-error properties field definitely exists in each row.
    properties: row.properties || {},
  })) as TRow[]

  const formattedDBRows = dbRows.map(({ id, properties }) => {
    const name =
      properties?.[NOTION_DB_PROPERTY_NAME]?.title?.[0]?.text?.content || ''
    const link = properties?.[NOTION_DB_PROPERTY_LINK]?.url || ''
    const dueDate = properties?.[NOTION_DB_PROPERTY_DUE_DATE]?.date || {
      start: '',
      end: '',
    }

    return {
      id,
      [NOTION_DB_PROPERTY_NAME]: name,
      [NOTION_DB_PROPERTY_LINK]: link,
      [NOTION_DB_PROPERTY_DUE_DATE]: dueDate,
    }
  })

  return (
    <div className='mt-8 flex justify-center'>
      <div className='w-full max-w-4xl'>
        <NotionTable initialTableData={formattedDBRows} />
      </div>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Here, we first fetch the user's Notion database information using the fetchNotionDB function. Then, we check the type of the function's return data to ensure it is of the QueryDatabaseResponse type. If it is not, we simply show an error message and return.

💡 The comment at the top of the properties assignment is to suppress the error that suggests a row might be of another type and may not have a properties field. However, querying a database always returns a properties field for each row.

Next, we fetch all the rows of the Notion database and cast them as TRow[]. Since it contains some data that isn’t particularly relevant to us, we format it into the formattedDBRows variable as TRowDetails[].

Finally, we pass this fetched data to the <NotionTable /> component, which handles displaying the table in the UI.

Error Handling Utility

Notice that we haven’t yet implemented the isErrorResponse function. Add a utility function to determine if a response is an error. Update lib/utils.ts as follows:

// 👇 lib/utils.ts

import { TResponse } from '@/types/notion'
import { QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints'

// ...Rest of the code

export function isErrorResponse(
  data: QueryDatabaseResponse | TResponse,
): data is TResponse {
  if (
    typeof data === 'object' &&
    data !== null &&
    'success' in data &&
    typeof (data as TResponse).success === 'boolean'
  ) {
    return (data as TResponse).success === false
  }
  return false
}

Enter fullscreen mode Exit fullscreen mode

This function checks whether the data is of type TResponse and if the success field is set to false. If yes, we return False, else we return True.

Adding and Configuring UI Components

Now, all we need to do is implement the <NotionTable /> component. Firstly before doing that, let’s add a few shadcn/ui components we will use.

Install required UI components:

npx shadcn@latest add sonner table

Enter fullscreen mode Exit fullscreen mode

Enable the Sonner toast notifications by adding its provider to app/layout.tsx:

// 👇 app/layout.tsx

import { Toaster } from '@/components/ui/sonner'

// ...Rest of the code

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang='en'>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {/* ...Rest of the code */}
          <main>{children}</main>
          <Toaster />
        {/* ...Rest of the code */}
      </body>
    </html>
  )
}

Enter fullscreen mode Exit fullscreen mode

Rendering the Notion Database

Create the NotionTable component to render the fetched data. This component is also going to hold most of the AI works.

Add a file components/notion-table.tsx with the following code:

// 👇 components/notion-table.tsx

'use client'

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'
import { updateNotionDBRowLink, updateNotionDBRowTitle } from '@/lib/actions'
import { TRowDetails } from '@/types/notion'
import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'

interface NotionTableProps {
  initialTableData: TRowDetails[]
}

export const NotionTable = ({ initialTableData }: NotionTableProps) => {
  const [tableData, setTableData] = useState<TRowDetails[]>(initialTableData)

  return (
    <Table className='rounded-sm shadow-sm'>
      <TableCaption className='py-4'>Notion Database</TableCaption>
      <TableHeader className='bg-zinc-100'>
        <TableRow>
          <TableHead>Name</TableHead>
          <TableHead>Link</TableHead>
          <TableHead className='text-right'>Due Date</TableHead>
        </TableRow>
      </TableHeader>
      {initialTableData.length === 0 ? (
        <p className='text-center text-zinc-500'>No data found.</p>
      ) : (
        <TableBody>
          {tableData.map((dbRow, i) => (
            <TableRow key={`${dbRow.name}-${dbRow.id}-${i}`}>
              <TableCell className='font-medium'>
                {dbRow.name ? (
                  <span>{dbRow.name}</span>
                ) : (
                  <span className='text-zinc-500'>Unnamed</span>
                )}
              </TableCell>
              <TableCell>
                {dbRow.link ? (
                  <Link
                    href={dbRow.link}
                    aria-label={`Link for ${dbRow.name || 'Unnamed'}`}
                    target='_blank'
                    className='underline underline-offset-4'
                  >
                    {dbRow.link}
                  </Link>
                ) : (
                  <span className='text-zinc-500'>No Link</span>
                )}
              </TableCell>
              <TableCell className='text-right'>
                {dbRow.dueDate.start ? (
                  <span>{dbRow.dueDate.start}</span>
                ) : (
                  <span className='text-zinc-500'>No Due Date</span>
                )}
                {dbRow.dueDate.end ? ` - ${dbRow.dueDate.end}` : null}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      )}
    </Table>
  )
}

Enter fullscreen mode Exit fullscreen mode

To enable AI to understand and interact with our application, we will leverage a few hooks from CopilotKit.

Specifically, we'll use the useCopilotReadable and useCopilotAction hooks to provide AI with both context and the ability to perform actions within our app.

  • Provide Read Access with useCopilotReadable

In the notion-table.tsx, add the useCopilotReadable hook to make the AI aware of the application's state:

  // ...Rest of the code

  useCopilotReadable({
    description:
      'All the rows in our notion database which holds the information for all the meetings I need to attend.',
    value: tableData,
  })

  // ...Rest of the code

Enter fullscreen mode Exit fullscreen mode

By adding this, CopilotKit gains a real-time understanding of the tableData state, allowing the AI to answer queries about the Notion database.

  • Enable write access with useCopilotAction

Next, we implement the ability for AI to modify the data. Add the following hooks below the useCopilotReadable in the same file to allow updates to row details:

// 👇 components/notion-table.tsx

// ...Rest of the code

  useCopilotAction({
    name: 'updateRowName',
    description:
      'Update the title of the row (index starts from 0) in the notion database.',
    parameters: [
      {
        name: 'index',
        description: 'Index of the row to update.',
        required: true,
      },
      {
        name: 'newTitle',
        description: 'New title for the row.',
        required: true,
      },
    ],
    handler: async ({ index, newTitle }) => {
      const parsedIndex = parseInt(index, 10)
      if (isNaN(parsedIndex)) throw new Error('Invalid index')

      const { success } = await updateNotionDBRowTitle({
        tableRowId: tableData[parsedIndex].id,
        tableRowNewTitle: newTitle,
      })

      if (!success) return toast.error('Could not update the notion DB')

      toast.success('Successfully updated the notion DB')
      setTableData(prevData => {
        const updatedTableData = [...prevData]
        if (parsedIndex >= 0 && parsedIndex < updatedTableData.length) {
          updatedTableData[parsedIndex].name = newTitle
        }
        return updatedTableData
      })
    },
  })

  useCopilotAction({
    name: 'updateRowLink',
    description:
      'Update the link of the row (index starts from 0) in the notion database.',
    parameters: [
      {
        name: 'index',
        description: 'Index of the row to update.',
        required: true,
      },
      {
        name: 'newLink',
        description: 'New link to the row.',
        required: true,
      },
    ],
    handler: async ({ index, newLink }) => {
      const parsedIndex = parseInt(index, 10)
      if (isNaN(parsedIndex)) throw new Error('Invalid index')

      const { success } = await updateNotionDBRowLink({
        tableRowId: tableData[parsedIndex].id,
        tableRowNewLink: newLink,
      })

      if (!success) return toast.error('Could not update the notion DB')

      toast.success('Successfully updated the notion DB')
      setTableData(prevData => {
        const updatedTableData = [...prevData]
        if (parsedIndex >= 0 && parsedIndex < updatedTableData.length) {
          updatedTableData[parsedIndex].link = newLink
        }
        return updatedTableData
      })
    },
  })

// ...Rest of the code

Enter fullscreen mode Exit fullscreen mode

Each useCopilotAction hook includes:

  • name: The action's identifier.
  • description: A clear explanation of the action.
  • parameters: The inputs required for the action.
  • handler: The function that executes the action, modifying the database and updating the UI accordingly.

Implement Database Update Functions

We have not yet written the updateNotionDBRowTitle and the updateNotionDBRowLink function. In the lib/actions.ts file, define the helper functions to interact with the Notion API:

// 👇 lib/actions.ts

'use server'

import {
  NOTION_DB_PROPERTY_LINK,
  NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'

// ...Rest of the code

export const updateNotionDBRowTitle = async ({
  tableRowId,
  tableRowNewTitle,
}: {
  tableRowId: string
  tableRowNewTitle: string
}): Promise<TResponse> => {
  try {
    await notion.pages.update({
      page_id: tableRowId,
      properties: {
        [NOTION_DB_PROPERTY_NAME]: {
          title: [{ text: { content: tableRowNewTitle } }],
        },
      },
    })
    return { success: true, error: null } as TResponse
  } catch (error) {
    return { success: false, error: error as Error } as TResponse
  }
}

export const updateNotionDBRowLink = async ({
  tableRowId,
  tableRowNewLink,
}: {
  tableRowId: string
  tableRowNewLink: string
}): Promise<TResponse> => {
  try {
    await notion.pages.update({
      page_id: tableRowId,
      properties: {
        [NOTION_DB_PROPERTY_LINK]: {
          url: tableRowNewLink,
        },
      },
    })
    return { success: true, error: null } as TResponse
  } catch (error) {
    return { success: false, error: error as Error } as TResponse
  }
}

Enter fullscreen mode Exit fullscreen mode

Both functions use the Notion API to update specific fields of a row (or "page") in the database, returning success or error responses.

That is all we will be implementing in our application today. You also have the function to delete a row in a database. Feel free to explore the notion API docs and implement it by yourself. 😎

Conclusion ⚡

Wow! Now, we have a fully functioning AI-automated application that can fetch a user's data from their Notion database, answer user queries on the data, and even change the data if needed. 🫨

If you got confused while coding along, the entire documented source code for this article is available here: Github Repo

Star the CopilotKit repository ⭐

Follow CopilotKit for more content like this.

Share your thoughts in the comment section below! 👇

Thank you so much for reading! 🎉 🫡

Thank You GIF

Top comments (12)

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

This is an awesome tutorial Arindam!

Collapse
 
arindam_1729 profile image
Arindam Majumder

Thanks for Checking this out Nathan!

Collapse
 
shricodev profile image
Shrijal Acharya

Great one. 👏🏻

Collapse
 
arindam_1729 profile image
Arindam Majumder

Thanks for checking out!

Collapse
 
ddebajyati profile image
Debajyati Dey

Awesomely detailed tutorial! Will try this out for sure!

Collapse
 
arindam_1729 profile image
Arindam Majumder

Great. Let me know what you build with it!

Collapse
 
david-723 profile image
David

This looks like a worthy project to build.
Thanks!

Collapse
 
arindam_1729 profile image
Arindam Majumder

Glad you liked it.

Collapse
 
steven0121 profile image
Steven

Can CopilotKit work in Google sheets?

Collapse
 
prankurpandeyy profile image
PRANKUR PANDEY

Awesome tutorial I will build something cool on it

Collapse
 
arindam_1729 profile image
Arindam Majumder

Awesome. Let me know how that goes!

Collapse
 
ferguson0121 profile image
Ferguson

Cool!