Full Stack Chat App

View as markdown

In this example, you will learn how to build a chatbot that:

  • Lets users connect their various apps to the chatbot using the Composio SDK.
  • Uses the Vercel provider in the Composio SDK to handle and execute tool calls from the LLM.

This page gives a high-level overview of the Composio SDK and how it is used in the GitHub repository: composiohq/chat. You can find the demo live here.

Prerequisites

Ensure you've followed the README.md in the composiohq/chat repository to set up the project locally.

Creating auth configs

For all the apps you want to connect to the chatbot, you need to create their respective auth configs. Learn how to create auth configs here. Once done, your auth configs will be available in the Composio dashboard.

Auth configs

Save auth config IDs to environment variables

For this project, the auth config IDs should be saved to the environment variables with the NEXT_PUBLIC_ prefix.

NEXT_PUBLIC_GMAIL_AUTH_CONFIG_ID=ac_1234567890
NEXT_PUBLIC_GITHUB_AUTH_CONFIG_ID=ac_1234567890

Create a Composio client instance

We create a Composio client instance for server-side operations like API routes, server components, etc.

import { class Composio<TProvider extends BaseComposioProvider<unknown, unknown, unknown> = OpenAIProvider>
This is the core class for Composio. It is used to initialize the Composio SDK and provide a global configuration.
Composio
} from '@composio/core';
import { class VercelProviderVercelProvider } from '@composio/vercel'; const const composio: Composio<VercelProvider>composio = new new Composio<VercelProvider>(config?: ComposioConfig<VercelProvider> | undefined): Composio<VercelProvider>
Creates a new instance of the Composio SDK. The constructor initializes the SDK with the provided configuration options, sets up the API client, and initializes all core models (tools, toolkits, etc.).
@paramconfig - Configuration options for the Composio SDK@paramconfig.apiKey - The API key for authenticating with the Composio API@paramconfig.baseURL - The base URL for the Composio API (defaults to production URL)@paramconfig.allowTracking - Whether to allow anonymous usage analytics@paramconfig.provider - The provider to use for this Composio instance (defaults to OpenAIProvider)@example```typescript // Initialize with default configuration const composio = new Composio(); // Initialize with custom API key and base URL const composio = new Composio({ apiKey: 'your-api-key', baseURL: 'https://api.composio.dev' }); // Initialize with custom provider const composio = new Composio({ apiKey: 'your-api-key', provider: new CustomProvider() }); ```
Composio
({
apiKey?: string | null | undefined
The API key for the Composio API.
@example'sk-1234567890'
apiKey
: var process: NodeJS.Processprocess.NodeJS.Process.env: NodeJS.ProcessEnv
The `process.env` property returns an object containing the user environment. See [`environ(7)`](http://man7.org/linux/man-pages/man7/environ.7.html). An example of this object looks like: ```js { TERM: 'xterm-256color', SHELL: '/usr/local/bin/bash', USER: 'maciej', PATH: '~/.bin/:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin', PWD: '/Users/maciej', EDITOR: 'vim', SHLVL: '1', HOME: '/Users/maciej', LOGNAME: 'maciej', _: '/usr/local/bin/node' } ``` It is possible to modify this object, but such modifications will not be reflected outside the Node.js process, or (unless explicitly requested) to other `Worker` threads. In other words, the following example would not work: ```bash node -e 'process.env.foo = "bar"' &#x26;&#x26; echo $foo ``` While the following will: ```js import { env } from 'node:process'; env.foo = 'bar'; console.log(env.foo); ``` Assigning a property on `process.env` will implicitly convert the value to a string. **This behavior is deprecated.** Future versions of Node.js may throw an error when the value is not a string, number, or boolean. ```js import { env } from 'node:process'; env.test = null; console.log(env.test); // => 'null' env.test = undefined; console.log(env.test); // => 'undefined' ``` Use `delete` to delete a property from `process.env`. ```js import { env } from 'node:process'; env.TEST = 1; delete env.TEST; console.log(env.TEST); // => undefined ``` On Windows operating systems, environment variables are case-insensitive. ```js import { env } from 'node:process'; env.TEST = 1; console.log(env.test); // => 1 ``` Unless explicitly specified when creating a `Worker` instance, each `Worker` thread has its own copy of `process.env`, based on its parent thread's `process.env`, or whatever was specified as the `env` option to the `Worker` constructor. Changes to `process.env` will not be visible across `Worker` threads, and only the main thread can make changes that are visible to the operating system or to native add-ons. On Windows, a copy of `process.env` on a `Worker` instance operates in a case-sensitive manner unlike the main thread.
@sincev0.1.27
env
.string | undefinedCOMPOSIO_API_KEY,
provider?: VercelProvider | undefined
The tool provider to use for this Composio instance.
@examplenew OpenAIProvider()
provider
: new
new VercelProvider({ strict }?: {
    strict?: boolean;
}): VercelProvider
Creates a new instance of the VercelProvider. This provider enables integration with the Vercel AI SDK, allowing Composio tools to be used with Vercel AI applications.
@example```typescript // Initialize the Vercel provider const provider = new VercelProvider(); // Use with Composio const composio = new Composio({ apiKey: 'your-api-key', provider: new VercelProvider() }); // Use the provider to wrap tools for Vercel AI SDK const vercelTools = provider.wrapTools(composioTools, composio.tools.execute); ```
VercelProvider
(),
}); export default const composio: Composio<VercelProvider>composio;

Creating an API for fetching toolkits

The Composio SDK is meant to be used only in server-side code. For client-side functionality, we create API endpoints in the /app/api/ directory. In order to list the toolkits and their connection status, we create a Next.js API route to fetch the toolkits using Composio SDK.

1. Listing connected accounts

First, we fetch all connected accounts for a user and create a mapping of toolkit slugs to their connection IDs:

export async function function GET(): Promise<void>GET() {
  // ... auth logic ...

  // List connected accounts to get connection IDs for each toolkit
  const const connectedAccounts: anyconnectedAccounts = await composio.connectedAccounts.list({
    userIds: any[]userIds: [session.user.id],
  });

  const const connectedToolkitMap: Map<any, any>connectedToolkitMap = new 
var Map: MapConstructor
new () => Map<any, any> (+3 overloads)
Map
();
const connectedAccounts: anyconnectedAccounts.items.forEach(account: anyaccount => { const connectedToolkitMap: Map<any, any>connectedToolkitMap.Map<any, any>.set(key: any, value: any): Map<any, any>
Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.
set
(account: anyaccount.toolkit.slug.toUpperCase(), account: anyaccount.id);
}); // ... continue with toolkit fetching ... }

2. Fetching toolkit data and building response

Next, we fetch toolkit information for each supported toolkit and combine it with the connection status:

export async function function GET(): Promise<any>GET() {
  // ... auth logic ...
  // ... connected accounts mapping ...

  const const SUPPORTED_TOOLKITS: string[]SUPPORTED_TOOLKITS = ['GMAIL', 'GOOGLECALENDAR', 'GITHUB', 'NOTION'];

  // Fetch toolkit data from slugs
  const 
const toolkitPromises: Promise<{
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}>[]
toolkitPromises
= const SUPPORTED_TOOLKITS: string[]SUPPORTED_TOOLKITS.
Array<string>.map<Promise<{
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}>>(callbackfn: (value: string, index: number, array: string[]) => Promise<{
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}>, thisArg?: any): Promise<{
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}>[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
(async slug: stringslug => {
const const toolkit: anytoolkit = await composio.toolkits.get(slug: stringslug); const const connectionId: anyconnectionId = connectedToolkitMap.get(slug: stringslug.String.toUpperCase(): string
Converts all the alphabetic characters in a string to uppercase.
toUpperCase
());
return { name: anyname: const toolkit: anytoolkit.name, slug: anyslug: const toolkit: anytoolkit.slug, description: anydescription: const toolkit: anytoolkit.meta?.description, logo: anylogo: const toolkit: anytoolkit.meta?.logo, categories: anycategories: const toolkit: anytoolkit.meta?.categories, isConnected: booleanisConnected: !!const connectionId: anyconnectionId, connectionId: anyconnectionId: const connectionId: anyconnectionId || var undefinedundefined, }; }); const
const toolkits: {
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}[]
toolkits
= await var Promise: PromiseConstructor
Represents the completion of an asynchronous operation
Promise
.
PromiseConstructor.all<Promise<{
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}>[]>(values: Promise<{
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}>[]): Promise<{
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}[]> (+1 overload)
Creates a Promise that is resolved with an array of results when all of the provided Promises resolve, or rejected when any Promise is rejected.
@paramvalues An array of Promises.@returnsA new Promise.
all
(
const toolkitPromises: Promise<{
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}>[]
toolkitPromises
);
return NextResponse.json({
toolkits: {
    name: any;
    slug: any;
    description: any;
    logo: any;
    categories: any;
    isConnected: boolean;
    connectionId: any;
}[]
toolkits
});
}

Managing connections

Users need to connect and disconnect their accounts from the chatbot to enable tool usage. When users click "Connect" on a toolkit, we initiate an OAuth flow, and when they click "Disconnect", we remove their connection.

1. Initiating a connection

When a user wants to connect their account, we create a connection request that redirects them to the OAuth provider:

export async function function POST(request: Request): Promise<any>POST(request: Requestrequest: Request) {
  // ... auth and validation ...

  const { const authConfigId: anyauthConfigId } = requestBody;

  // Initiate connection with Composio
  const const connectionRequest: anyconnectionRequest = await composio.connectedAccounts.initiate(
    session.user.id,
    const authConfigId: anyauthConfigId
  );

  return NextResponse.json({
    redirectUrl: anyredirectUrl: const connectionRequest: anyconnectionRequest.redirectUrl,
    connectionId: anyconnectionId: const connectionRequest: anyconnectionRequest.id,
  });
}

2. Checking connection status

After initiating a connection, we need to wait for the OAuth flow to complete. We check the connection status to know when it's ready to use:

export async function function GET(request: Request): Promise<any>GET(request: Requestrequest: Request) {
  // ... auth and validation ...

  const const connectionId: anyconnectionId = searchParams.get('connectionId');

  // Wait for connection to complete
  const const connection: anyconnection = await composio.connectedAccounts.waitForConnection(const connectionId: anyconnectionId);

  return NextResponse.json({
    id: anyid: const connection: anyconnection.id,
    status: anystatus: const connection: anyconnection.status,
    authConfig: anyauthConfig: const connection: anyconnection.authConfig,
    data: anydata: const connection: anyconnection.data,
  });
}

3. Deleting a connection

When a user wants to disconnect their account, we remove the connection using the connection ID:

export async function function DELETE(request: Request): Promise<any>DELETE(request: Requestrequest: Request) {
  // ... auth and validation ...

  const const connectionId: anyconnectionId = searchParams.get('connectionId');

  // Delete the connection
  await composio.connectedAccounts.delete(const connectionId: anyconnectionId);

  return NextResponse.json({
    success: booleansuccess: true,
    message: stringmessage: 'Connection deleted successfully',
  });
}

Working with tools

Once users have connected their accounts, we need to track which toolkits are enabled and fetch the corresponding tools for the LLM.

1. Tracking enabled toolkits

We keep track of which toolkits the user has enabled in the chat interface:

const { ... } = useChat({
    // ... other config ...
    
experimental_prepareRequestBody: (body: any) => {
    enabledToolkits: unknown[];
}
experimental_prepareRequestBody
: (body: anybody) => {
// Get current toolbar state const const currentToolbarState: anycurrentToolbarState = toolbarStateRef.current; const const enabledToolkits: unknown[]enabledToolkits = var Array: ArrayConstructorArray.ArrayConstructor.from<unknown>(iterable: Iterable<unknown> | ArrayLike<unknown>): unknown[] (+3 overloads)
Creates an array from an iterable object.
@paramiterable An iterable object to convert to an array.
from
(
const currentToolbarState: anycurrentToolbarState.enabledToolkitsWithStatus.entries(), ).Array<unknown>.map<unknown>(callbackfn: (value: unknown, index: number, array: unknown[]) => unknown, thisArg?: any): unknown[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
(([slug: anyslug, isConnected: anyisConnected]) => ({ slug: anyslug, isConnected: anyisConnected }));
return { // ... other fields ... enabledToolkits: unknown[]enabledToolkits, }; }, // ... other handlers ... });

2. Fetching tools for enabled toolkits

We fetch Composio tools based on the enabled toolkit slugs:

export async function function getComposioTools(userId: string, toolkitSlugs: string[]): Promise<any>getComposioTools(userId: stringuserId: string, toolkitSlugs: string[]toolkitSlugs: string[]) {
  // ... validation ...

  const const tools: anytools = await composio.tools.get(userId: stringuserId, {
    toolkits: string[]toolkits: toolkitSlugs: string[]toolkitSlugs,
  });
  return const tools: anytools || {};
}
export async function function POST(request: Request): Promise<void>POST(request: Requestrequest: Request) {
  // ... auth and parsing ...

  const const toolkitSlugs: anytoolkitSlugs = enabledToolkits?.map(t: anyt => t: anyt.slug) || [];

  const const composioTools: anycomposioTools = await getComposioTools(session.user.id, const toolkitSlugs: anytoolkitSlugs);

  const const result: anyresult = streamText({
    // ... model config ...
    tools: anytools: {
      ...const composioTools: anycomposioTools,
    },
  });
}

Bonus: Creating custom component to show tool calls

By default, tool calls appear as raw JSON in the chat interface. To create a better user experience, we can build custom components that display tool calls with proper formatting and loading states.

You can find the ToolCall component at components/tool-call.tsx. Here's how to integrate it into your message rendering:

if (type === 'tool-invocation') {
  const { const toolInvocation: anytoolInvocation } = part;
  const { const toolName: anytoolName, const toolCallId: anytoolCallId, const state: anystate, const args: anyargs, const result: anyresult } = const toolInvocation: anytoolInvocation;

  if (const state: anystate === 'call') {
    return (
      <type ToolCall = /*unresolved*/ anyToolCall
        key={toolCallId: anytoolCallId}
        const toolName: anytoolName={toolName: anytoolName}
        const args: anyargs={args: anyargs}
        isLoading={true: anytrue}
      />
    );
  }

  if (const state: anystate === 'result') {
    return (
      <type ToolCall = /*unresolved*/ anyToolCall
        key={toolCallId: anytoolCallId}
        const toolName: anytoolName={toolName: anytoolName}
        const args: anyargs={args: anyargs}
        const result: anyresult={result: anyresult}
        isLoading={false: anyfalse}
      />
    );
  }
}