Repository: lightdash/lightdash
Pull request #14862: feat: add AI agents database schema and migration
Author: login: notgiorgi
Labels: name: released
Created: 2025-05-16T17:31:09Z | Merged: 2025-05-21T12:40:55Z

<!-- Thanks so much for your PR, your contribution is appreciated! ❤️ -->

Closes: #14847

### Description:
Add database schema for AI Agents, migrate old slack-first schema

- Creates `ai_agent` table to store agent configurations with name, description, and data sources
- Creates `ai_agent_integration` table to manage integrations (currently supporting Slack)
- Adds `agent_uuid` column to the existing `ai_thread` table
- Includes a migration to convert existing Slack channel mappings to the new agent structure
- Migrations are revertable and do not delete any data
- All the existing features work as expected

Before:
![mermaid_before.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/SRLEqMEevAzoFwvhnfeq/8b4e071e-4fc9-4483-a01a-0b3e290622f3.png)

After:

![updated_diagram.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/SRLEqMEevAzoFwvhnfeq/4fdc5fc6-0f9b-43e8-84cc-8e58e5e834d7.png)




Diff:
diff --git a/packages/backend/src/ee/clients/Slack/SlackBot.ts b/packages/backend/src/ee/clients/Slack/SlackBot.ts
index 4f1971a4a0145..c6edf0e93c84a 100644
--- a/packages/backend/src/ee/clients/Slack/SlackBot.ts
+++ b/packages/backend/src/ee/clients/Slack/SlackBot.ts
@@ -7,6 +7,7 @@ import {
 } from '@slack/bolt';
 import { SlackBot, SlackBotArguments } from '../../../clients/Slack/Slackbot';
 import Logger from '../../../logging/logger';
+import { AiAgentModel } from '../../models/AiAgentModel';
 import { AiModel } from '../../models/AiModel';
 import { CommercialSlackAuthenticationModel } from '../../models/CommercialSlackAuthenticationModel';
 import { CommercialSchedulerClient } from '../../scheduler/SchedulerClient';
@@ -16,14 +17,15 @@ import {
     followUpToolsText,
 } from '../../services/AiService/utils/aiCopilot/followUpTools';
 import {
+    AiAgentNotFoundError,
     AiDuplicateSlackPromptError,
-    AiSlackMappingNotFoundError,
 } from '../../services/AiService/utils/errors';
 
 type CommercialSlackBotArguments = SlackBotArguments & {
     schedulerClient: CommercialSchedulerClient;
     aiService: AiService;
     aiModel: AiModel;
+    aiAgentModel: AiAgentModel;
     slackAuthenticationModel: CommercialSlackAuthenticationModel;
 };
 
@@ -34,6 +36,8 @@ export class CommercialSlackBot extends SlackBot {
 
     private readonly aiModel: AiModel;
 
+    private readonly aiAgentModel: AiAgentModel;
+
     slackAuthenticationModel: CommercialSlackAuthenticationModel;
 
     constructor(args: CommercialSlackBotArguments) {
@@ -41,6 +45,7 @@ export class CommercialSlackBot extends SlackBot {
         this.schedulerClient = args.schedulerClient;
         this.aiService = args.aiService;
         this.aiModel = args.aiModel;
+        this.aiAgentModel = args.aiAgentModel;
         this.slackAuthenticationModel = args.slackAuthenticationModel;
     }
 
@@ -236,6 +241,7 @@ export class CommercialSlackBot extends SlackBot {
                                             prevSlackPrompt.slackThreadTs,
                                         prompt: response.message.text,
                                         promptSlackTs: response.ts,
+                                        agentUuid: prevSlackPrompt.agentUuid,
                                     });
                             } catch (e) {
                                 if (e instanceof AiDuplicateSlackPromptError) {
@@ -294,20 +300,22 @@ export class CommercialSlackBot extends SlackBot {
         let createdThread: boolean;
 
         try {
-            const projectSettings =
-                await this.slackAuthenticationModel.getProjectSettingsForSlackChannelId(
+            const agentConfig =
+                await this.aiAgentModel.getAgentBySlackChannelId({
                     organizationUuid,
-                    event.channel,
-                );
+                    slackChannelId: event.channel,
+                });
+
             [slackPromptUuid, createdThread] =
                 await this.aiService.createSlackPrompt({
                     userUuid,
-                    projectUuid: projectSettings.projectUuid,
+                    projectUuid: agentConfig.projectUuid,
                     slackUserId: event.user,
                     slackChannelId: event.channel,
                     slackThreadTs: event.thread_ts,
                     prompt: event.text,
                     promptSlackTs: event.ts,
+                    agentUuid: agentConfig.uuid ?? null,
                 });
         } catch (e) {
             if (e instanceof AiDuplicateSlackPromptError) {
@@ -315,8 +323,8 @@ export class CommercialSlackBot extends SlackBot {
                 return;
             }
 
-            if (e instanceof AiSlackMappingNotFoundError) {
-                Logger.debug('Failed to find slack mapping:', e);
+            if (e instanceof AiAgentNotFoundError) {
+                Logger.debug('Failed to find ai agent:', e);
                 return;
             }
 
diff --git a/packages/backend/src/ee/database/entities/ai.ts b/packages/backend/src/ee/database/entities/ai.ts
index 1d10e318bfcc8..d65078c841ef3 100644
--- a/packages/backend/src/ee/database/entities/ai.ts
+++ b/packages/backend/src/ee/database/entities/ai.ts
@@ -3,6 +3,7 @@ import { Knex } from 'knex';
 export const AiThreadTableName = 'ai_thread';
 
 export type DbAiThread = {
+    agent_uuid: string | null;
     ai_thread_uuid: string;
     created_at: Date;
     organization_uuid: string;
@@ -12,7 +13,10 @@ export type DbAiThread = {
 
 export type AiThreadTable = Knex.CompositeTableType<
     DbAiThread,
-    Pick<DbAiThread, 'organization_uuid' | 'project_uuid' | 'created_from'>
+    Pick<
+        DbAiThread,
+        'organization_uuid' | 'project_uuid' | 'created_from' | 'agent_uuid'
+    >
 >;
 
 export const AiSlackThreadTableName = 'ai_slack_thread';
diff --git a/packages/backend/src/ee/database/entities/aiAgent.ts b/packages/backend/src/ee/database/entities/aiAgent.ts
new file mode 100644
index 0000000000000..42c3de5d56394
--- /dev/null
+++ b/packages/backend/src/ee/database/entities/aiAgent.ts
@@ -0,0 +1,41 @@
+import { Knex } from 'knex';
+
+export const AiAgentTableName = 'ai_agent';
+
+export type DbAiAgent = {
+    ai_agent_uuid: string;
+    organization_uuid: string;
+    project_uuid: string;
+    name: string;
+    description: string;
+    image_url: string;
+    tags: string[] | null;
+    created_at: Date;
+};
+
+export type AiAgentTable = Knex.CompositeTableType<DbAiAgent>;
+
+export const AiAgentIntegrationTableName = 'ai_agent_integration';
+
+export type DbAiAgentIntegration = {
+    ai_agent_integration_uuid: string;
+    ai_agent_uuid: string;
+    integration_type: string;
+    created_at: Date;
+};
+
+export type AiAgentIntegrationTable =
+    Knex.CompositeTableType<DbAiAgentIntegration>;
+
+export const AiAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export type DbAiAgentSlackIntegration = {
+    ai_agent_integration_slack_uuid: string;
+    organization_uuid: string;
+    ai_agent_integration_uuid: string;
+    slack_channel_id: string;
+    created_at: Date;
+};
+
+export type AiAgentSlackIntegrationTable =
+    Knex.CompositeTableType<DbAiAgentSlackIntegration>;
diff --git a/packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts b/packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
new file mode 100644
index 0000000000000..b36e52afa487c
--- /dev/null
+++ b/packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
@@ -0,0 +1,41 @@
+import { Knex } from 'knex';
+
+const AIAgentTableName = 'ai_agent';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable(AIAgentTableName)) return;
+
+    await knex.schema.createTable(AIAgentTableName, (table) => {
+        table
+            .uuid('ai_agent_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+
+        table
+            .uuid('project_uuid')
+            .notNullable()
+            .references('project_uuid')
+            .inTable('projects')
+            .onDelete('CASCADE');
+        table
+            .uuid('organization_uuid')
+            .notNullable()
+            .references('organization_uuid')
+            .inTable('organizations')
+            .onDelete('CASCADE');
+
+        table.string('name').notNullable();
+        table.text('description');
+        table.text('image_url');
+        table.specificType('tags', 'text[]').defaultTo(null);
+        table
+            .timestamp('created_at', { useTz: false })
+            .notNullable()
+            .defaultTo(knex.fn.now());
+    });
+}
+
+export async function down(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(AIAgentTableName))) return;
+    await knex.schema.dropTable(AIAgentTableName);
+}
diff --git a/packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts b/packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts
new file mode 100644
index 0000000000000..1526db0823731
--- /dev/null
+++ b/packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts
@@ -0,0 +1,69 @@
+import { Knex } from 'knex';
+
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(AIAgentIntegrationTableName))) {
+        await knex.schema.createTable(AIAgentIntegrationTableName, (table) => {
+            table
+                .uuid('ai_agent_integration_uuid')
+                .primary()
+                .defaultTo(knex.raw('uuid_generate_v4()'));
+            table
+                .uuid('ai_agent_uuid')
+                .references('ai_agent_uuid')
+                .inTable('ai_agent')
+                .onDelete('CASCADE')
+                .notNullable();
+            table.enum('integration_type', ['slack']).notNullable();
+            table
+                .timestamp('created_at', { useTz: false })
+                .notNullable()
+                .defaultTo(knex.fn.now());
+        });
+    }
+
+    if (!(await knex.schema.hasTable(AIAgentSlackIntegrationTableName))) {
+        await knex.schema.createTable(
+            AIAgentSlackIntegrationTableName,
+            (table) => {
+                table
+                    .uuid('ai_agent_integration_slack_uuid')
+                    .primary()
+                    .defaultTo(knex.raw('uuid_generate_v4()'));
+                table
+                    .uuid('organization_uuid')
+                    .notNullable()
+                    .references('organization_uuid')
+                    .inTable('organizations')
+                    .onDelete('CASCADE');
+                table
+                    .uuid('ai_agent_integration_uuid')
+                    .notNullable()
+                    .unique()
+                    .references('ai_agent_integration_uuid')
+                    .inTable('ai_agent_integration')
+                    .onDelete('CASCADE');
+                table.text('slack_channel_id').notNullable();
+                table
+                    .timestamp('created_at', { useTz: false })
+                    .notNullable()
+                    .defaultTo(knex.fn.now());
+
+                table.unique(['organization_uuid', 'slack_channel_id']);
+            },
+        );
+    }
+}
+
+export async function down(knex: Knex): Promise<void> {
+    // drop in reverse order
+    if (await knex.schema.hasTable('ai_agent_slack_integration')) {
+        await knex.schema.dropTable('ai_agent_slack_integration');
+    }
+
+    if (await knex.schema.hasTable('ai_agent_integration')) {
+        await knex.schema.dropTable('ai_agent_integration');
+    }
+}
diff --git a/packages/backend/src/ee/database/migrations/20250516163327_add_ai_agent_uuid_to_ai_thread.ts b/packages/backend/src/ee/database/migrations/20250516163327_add_ai_agent_uuid_to_ai_thread.ts
new file mode 100644
index 0000000000000..01c409962b986
--- /dev/null
+++ b/packages/backend/src/ee/database/migrations/20250516163327_add_ai_agent_uuid_to_ai_thread.ts
@@ -0,0 +1,76 @@
+import { Knex } from 'knex';
+
+const AIThreadTableName = 'ai_thread';
+const AIAgentTableName = 'ai_agent';
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    await knex.schema.alterTable(AIThreadTableName, (table) => {
+        table
+            .uuid('agent_uuid')
+            .nullable()
+            .references('ai_agent_uuid')
+            .inTable('ai_agent')
+            .onDelete('CASCADE');
+    });
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    for await (const mapping of slackMappings) {
+        const [agent] = await knex(AIAgentTableName)
+            .insert({
+                project_uuid: mapping.project_uuid,
+                organization_uuid: mapping.organization_uuid,
+                name: '',
+                description: '',
+                image_url: '',
+                tags: mapping.available_tags,
+            })
+            .returning('ai_agent_uuid');
+
+        const [integration] = await knex(AIAgentIntegrationTableName)
+            .insert({
+                ai_agent_uuid: agent.ai_agent_uuid,
+                integration_type: 'slack',
+            })
+            .returning('ai_agent_integration_uuid');
+
+        await knex(AIAgentSlackIntegrationTableName).insert({
+            ai_agent_integration_uuid: integration.ai_agent_integration_uuid,
+            organization_uuid: mapping.organization_uuid,
+            slack_channel_id: mapping.slack_channel_id,
+        });
+
+        await knex.raw(
+            `
+                UPDATE ai_thread t
+                SET
+                    agent_uuid = ?
+                FROM
+                    ai_slack_thread st
+                WHERE
+                    st.ai_thread_uuid = t.ai_thread_uuid
+                    AND t.organization_uuid = ?
+                    AND st.slack_channel_id = ?`,
+            [
+                agent.ai_agent_uuid,
+                mapping.organization_uuid,
+                mapping.slack_channel_id,
+            ],
+        );
+    }
+}
+
+export async function down(knex: Knex): Promise<void> {
+    await knex.schema.alterTable(AIThreadTableName, (table) => {
+        table.dropForeign(['agent_uuid']);
+        table.dropColumn('agent_uuid');
+    });
+}
diff --git a/packages/backend/src/ee/index.ts b/packages/backend/src/ee/index.ts
index d7d371983bddc..c6db915977fcc 100644
--- a/packages/backend/src/ee/index.ts
+++ b/packages/backend/src/ee/index.ts
@@ -9,6 +9,7 @@ import { EncryptionUtil } from '../utils/EncryptionUtil/EncryptionUtil';
 import LicenseClient from './clients/License/LicenseClient';
 import OpenAi from './clients/OpenAi';
 import { CommercialSlackBot } from './clients/Slack/SlackBot';
+import { AiAgentModel } from './models/AiAgentModel';
 import { AiModel } from './models/AiModel';
 import { CommercialCatalogModel } from './models/CommercialCatalogModel';
 import { CommercialFeatureFlagModel } from './models/CommercialFeatureFlagModel';
@@ -95,17 +96,17 @@ export async function getEnterpriseAppArguments(): Promise<EnterpriseAppArgument
                         repository.getCatalogService() as CommercialCatalogService,
                     userModel: models.getUserModel(),
                     aiModel: models.getAiModel(),
+                    aiAgentModel: models.getAiAgentModel(),
                     projectModel: models.getProjectModel(),
                     openAi: new OpenAi(), // TODO This should go in client repository as soon as it is available
                     slackClient: clients.getSlackClient(),
                     lightdashConfig: context.lightdashConfig,
                     organizationModel: models.getOrganizationModel(),
-                    slackAuthenticationModel:
-                        models.getSlackAuthenticationModel() as CommercialSlackAuthenticationModel,
                     featureFlagService: repository.getFeatureFlagService(),
                 }),
             aiAgentService: ({ models, repository }) =>
                 new AiAgentService({
+                    aiAgentModel: models.getAiAgentModel(),
                     slackAuthenticationModel:
                         models.getSlackAuthenticationModel() as CommercialSlackAuthenticationModel,
                     featureFlagService: repository.getFeatureFlagService(),
@@ -223,6 +224,7 @@ export async function getEnterpriseAppArguments(): Promise<EnterpriseAppArgument
         },
         modelProviders: {
             aiModel: ({ database }) => new AiModel({ database }),
+            aiAgentModel: ({ database }) => new AiAgentModel({ database }),
             embedModel: ({ database }) => new EmbedModel({ database }),
             dashboardSummaryModel: ({ database }) =>
                 new DashboardSummaryModel({ database }),
@@ -287,6 +289,7 @@ export async function getEnterpriseAppArguments(): Promise<EnterpriseAppArgument
                 schedulerClient:
                     context.clients.getSchedulerClient() as CommercialSchedulerClient,
                 aiModel: context.models.getAiModel(),
+                aiAgentModel: context.models.getAiAgentModel(),
             }),
         clientProviders: {
             schedulerClient: ({ context, models }) =>
diff --git a/packages/backend/src/ee/models/AiAgentModel.ts b/packages/backend/src/ee/models/AiAgentModel.ts
new file mode 100644
index 0000000000000..72b79c6623c52
--- /dev/null
+++ b/packages/backend/src/ee/models/AiAgentModel.ts
@@ -0,0 +1,69 @@
+import { type AiAgent } from '@lightdash/common';
+import { Knex } from 'knex';
+import {
+    AiAgentIntegrationTableName,
+    AiAgentSlackIntegrationTableName,
+    AiAgentTableName,
+} from '../database/entities/aiAgent';
+import { AiAgentNotFoundError } from '../services/AiService/utils/errors';
+
+type Dependencies = {
+    database: Knex;
+};
+
+export class AiAgentModel {
+    private database: Knex;
+
+    constructor(dependencies: Dependencies) {
+        this.database = dependencies.database;
+    }
+
+    async getAgentBySlackChannelId({
+        organizationUuid,
+        slackChannelId,
+    }: {
+        organizationUuid: string;
+        slackChannelId: string;
+    }): Promise<AiAgent> {
+        // TODO: when `getAgent(uuid: string) is implemented, this should use it instead
+        const agent = await this.database(AiAgentTableName)
+            .select({
+                uuid: `${AiAgentTableName}.ai_agent_uuid`,
+                organizationUuid: `${AiAgentTableName}.organization_uuid`,
+                projectUuid: `${AiAgentTableName}.project_uuid`,
+                tags: `${AiAgentTableName}.tags`,
+                integrations: this.database.raw(`
+                    json_agg(
+                        json_build_object(
+                            'type', ${AiAgentIntegrationTableName}.integration_type,
+                            'channelId', ${AiAgentSlackIntegrationTableName}.slack_channel_id
+                        )
+                    )`),
+            } satisfies Record<keyof AiAgent, unknown>)
+            .join(
+                AiAgentIntegrationTableName,
+                `${AiAgentTableName}.ai_agent_uuid`,
+                `${AiAgentIntegrationTableName}.ai_agent_uuid`,
+            )
+            .join(
+                AiAgentSlackIntegrationTableName,
+                `${AiAgentIntegrationTableName}.ai_agent_integration_uuid`,
+                `${AiAgentSlackIntegrationTableName}.ai_agent_integration_uuid`,
+            )
+            .where({
+                [`${AiAgentTableName}.organization_uuid`]: organizationUuid,
+                [`${AiAgentSlackIntegrationTableName}.slack_channel_id`]:
+                    slackChannelId,
+            })
+            .groupBy(`${AiAgentTableName}.ai_agent_uuid`)
+            .first();
+
+        if (!agent) {
+            throw new AiAgentNotFoundError(
+                `AI agent not found for slack channel ID: ${slackChannelId}`,
+            );
+        }
+
+        return agent;
+    }
+}
diff --git a/packages/backend/src/ee/models/AiModel.ts b/packages/backend/src/ee/models/AiModel.ts
index 0a2b0421c1da2..33388fd7f4f72 100644
--- a/packages/backend/src/ee/models/AiModel.ts
+++ b/packages/backend/src/ee/models/AiModel.ts
@@ -43,6 +43,7 @@ export class AiModel {
                 projectUuid: 'project_uuid',
                 createdAt: 'created_at',
                 createdFrom: 'created_from',
+                agentUuid: 'agent_uuid',
             })
             .where({ ai_thread_uuid: uuid })
             .first();
@@ -92,13 +93,17 @@ export class AiModel {
                 Array<
                     Pick<
                         DbAiThread,
-                        'ai_thread_uuid' | 'created_at' | 'created_from'
+                        | 'ai_thread_uuid'
+                        | 'created_at'
+                        | 'created_from'
+                        | 'agent_uuid'
                     > &
                         Pick<DbAiPrompt, 'prompt'> &
                         Pick<DbUser, 'user_uuid'> & { user_name: string }
                 >
             >(
                 `${AiThreadTableName}.ai_thread_uuid`,
+                `${AiThreadTableName}.agent_uuid`,
                 `${AiThreadTableName}.created_at`,
                 `${AiThreadTableName}.created_from`,
                 `${AiPromptTableName}.prompt`,
@@ -176,6 +181,7 @@ export class AiModel {
                 projectUuid: `${AiThreadTableName}.project_uuid`,
                 promptUuid: `${AiPromptTableName}.ai_prompt_uuid`,
                 threadUuid: `${AiPromptTableName}.ai_thread_uuid`,
+                agentUuid: `${AiThreadTableName}.agent_uuid`,
                 createdByUserUuid: `${AiPromptTableName}.created_by_user_uuid`,
                 prompt: `${AiPromptTableName}.prompt`,
                 createdAt: `${AiPromptTableName}.created_at`,
@@ -213,6 +219,7 @@ export class AiModel {
                     organization_uuid: data.organizationUuid,
                     project_uuid: data.projectUuid,
                     created_from: data.createdFrom,
+                    agent_uuid: data.agentUuid,
                 })
                 .returning('ai_thread_uuid');
             if (row === undefined) {
@@ -307,6 +314,7 @@ export class AiModel {
                 projectUuid: `${AiThreadTableName}.project_uuid`,
                 promptUuid: `${AiPromptTableName}.ai_prompt_uuid`,
                 threadUuid: `${AiPromptTableName}.ai_thread_uuid`,
+                agentUuid: `${AiThreadTableName}.agent_uuid`,
                 createdByUserUuid: `${AiPromptTableName}.created_by_user_uuid`,
                 userUuid: `${AiWebAppPromptTableName}.user_uuid`,
                 prompt: `${AiPromptTableName}.prompt`,
@@ -328,6 +336,7 @@ export class AiModel {
                     organization_uuid: data.organizationUuid,
                     project_uuid: data.projectUuid,
                     created_from: data.createdFrom,
+                    agent_uuid: data.agentUuid,
                 })
                 .returning('ai_thread_uuid');
             if (row === undefined) {
diff --git a/packages/backend/src/ee/services/AiAgentService.ts b/packages/backend/src/ee/services/AiAgentService.ts
index b0f6588f697e3..614f9d9a3c53a 100644
--- a/packages/backend/src/ee/services/AiAgentService.ts
+++ b/packages/backend/src/ee/services/AiAgentService.ts
@@ -1,26 +1,22 @@
-import {
-    type AiAgent,
-    CommercialFeatureFlags,
-    ForbiddenError,
-    LightdashUser,
-    NotFoundError,
-    type SessionUser,
-} from '@lightdash/common';
-
 import { FeatureFlagService } from '../../services/FeatureFlag/FeatureFlagService';
+import { AiAgentModel } from '../models/AiAgentModel';
 import type { CommercialSlackAuthenticationModel } from '../models/CommercialSlackAuthenticationModel';
 
 type AiAgentServiceDependencies = {
+    aiAgentModel: AiAgentModel;
     slackAuthenticationModel: CommercialSlackAuthenticationModel;
     featureFlagService: FeatureFlagService;
 };
 
 export class AiAgentService {
+    private readonly aiAgentModel: AiAgentModel;
+
     private readonly slackAuthenticationModel: CommercialSlackAuthenticationModel;
 
     private readonly featureFlagService: FeatureFlagService;
 
     constructor(dependencies: AiAgentServiceDependencies) {
+        this.aiAgentModel = dependencies.aiAgentModel;
         this.slackAuthenticationModel = dependencies.slackAuthenticationModel;
         this.featureFlagService = dependencies.featureFlagService;
     }
diff --git a/packages/backend/src/ee/services/AiService/AiService.ts b/packages/backend/src/ee/services/AiService/AiService.ts
index b819bbe03c7fb..7e818163f4f9a 100644
--- a/packages/backend/src/ee/services/AiService/AiService.ts
+++ b/packages/backend/src/ee/services/AiService/AiService.ts
@@ -31,7 +31,6 @@ import {
     isDashboardChartTileType,
     isField,
     isSlackPrompt,
-    isTableCalculation,
 } from '@lightdash/common';
 import * as Sentry from '@sentry/node';
 import { AgentExecutor } from 'langchain/agents';
@@ -55,6 +54,7 @@ import {
     DashboardSummaryViewed,
 } from '../../analytics';
 import OpenAi from '../../clients/OpenAi';
+import { AiAgentModel } from '../../models/AiAgentModel';
 import { AiModel } from '../../models/AiModel';
 import type { CommercialSlackAuthenticationModel } from '../../models/CommercialSlackAuthenticationModel';
 import { DashboardSummaryModel } from '../../models/DashboardSummaryModel';
@@ -101,6 +101,7 @@ type Dependencies = {
     dashboardModel: DashboardModel;
     dashboardSummaryModel: DashboardSummaryModel;
     aiModel: AiModel;
+    aiAgentModel: AiAgentModel;
     userModel: UserModel;
     projectModel: ProjectModel;
     organizationModel: OrganizationModel;
@@ -109,7 +110,6 @@ type Dependencies = {
     openAi: OpenAi;
     slackClient: SlackClient;
     lightdashConfig: LightdashConfig;
-    slackAuthenticationModel: CommercialSlackAuthenticationModel;
     featureFlagService: FeatureFlagService;
 };
 
@@ -120,6 +120,8 @@ export class AiService {
 
     private readonly aiModel: AiModel;
 
+    private readonly aiAgentModel: AiAgentModel;
+
     private readonly userModel: UserModel;
 
     private readonly projectModel: ProjectModel;
@@ -132,8 +134,6 @@ export class AiService {
 
     private readonly catalogService: CommercialCatalogService;
 
-    private readonly slackAuthenticationModel: CommercialSlackAuthenticationModel;
-
     private readonly openAi: OpenAi;
 
     private readonly slackClient: SlackClient;
@@ -149,13 +149,13 @@ export class AiService {
         this.projectService = dependencies.projectService;
         this.openAi = dependencies.openAi;
         this.aiModel = dependencies.aiModel;
+        this.aiAgentModel = dependencies.aiAgentModel;
         this.userModel = dependencies.userModel;
         this.slackClient = dependencies.slackClient;
         this.projectModel = dependencies.projectModel;
         this.catalogService = dependencies.catalogService;
         this.lightdashConfig = dependencies.lightdashConfig;
         this.organizationModel = dependencies.organizationModel;
-        this.slackAuthenticationModel = dependencies.slackAuthenticationModel;
         this.featureFlagService = dependencies.featureFlagService;
     }
 
@@ -656,12 +656,12 @@ export class AiService {
 
         const { projectUuid } = slackOrWebAppPrompt;
 
-        const projectSettings =
+        const agentSettings =
             'slackChannelId' in slackOrWebAppPrompt
-                ? await this.slackAuthenticationModel.getProjectSettingsForSlackChannelId(
-                      user.organizationUuid,
-                      slackOrWebAppPrompt.slackChannelId,
-                  )
+                ? await this.aiAgentModel.getAgentBySlackChannelId({
+                      organizationUuid: user.organizationUuid,
+                      slackChannelId: slackOrWebAppPrompt.slackChannelId,
+                  })
                 : undefined;
 
         const {
@@ -674,20 +674,20 @@ export class AiService {
         } = this.getToolUtilities(
             user,
             slackOrWebAppPrompt,
-            projectSettings?.availableTags ?? null,
+            agentSettings?.tags ?? null,
         );
 
         const findFieldsTool = getFindFieldsTool({
             getExplore,
             searchFields,
-            availableTags: projectSettings?.availableTags ?? null,
+            availableTags: agentSettings?.tags ?? null,
         });
 
         const generateQueryFilters = getGenerateQueryFiltersTool({
             getExplore,
             promptUuid: slackOrWebAppPrompt.promptUuid,
             updatePrompt: this.aiModel.updateSlackResponse.bind(this.aiModel),
-            availableTags: projectSettings?.availableTags ?? null,
+            availableTags: agentSettings?.tags ?? null,
         });
 
         const generateBarVizConfigTool = getGenerateBarVizConfigTool({
@@ -806,7 +806,7 @@ ${e.llmOutput}
             await this.getMinimalExploreInformation(
                 user,
                 projectUuid,
-                projectSettings?.availableTags ?? null,
+                agentSettings?.tags ?? null,
             );
 
         const results = await agentExecutor.invoke({
@@ -844,6 +844,7 @@ ${e.llmOutput}
         slackThreadTs: string | undefined;
         prompt: string;
         promptSlackTs: string;
+        agentUuid: string | null;
     }): Promise<[string, boolean]> {
         let createdThread = false;
         let threadUuid: string | undefined;
@@ -882,6 +883,7 @@ ${e.llmOutput}
                 slackUserId: data.slackUserId,
                 slackChannelId: data.slackChannelId,
                 slackThreadTs: data.slackThreadTs || data.promptSlackTs,
+                agentUuid: data.agentUuid,
             });
         }
 
@@ -1186,6 +1188,7 @@ ${
             projectUuid,
             userUuid: user.userUuid,
             createdFrom: 'web_app',
+            agentUuid: null,
         });
 
         if (threadUuid === undefined) {
@@ -1239,6 +1242,7 @@ ${
             projectUuid,
             createdFrom: 'web_app',
             userUuid: user.userUuid,
+            agentUuid: null,
         });
 
         const promptUuid = await this._createWebAppPrompt(
diff --git a/packages/backend/src/ee/services/AiService/utils/errors.ts b/packages/backend/src/ee/services/AiService/utils/errors.ts
index 2e2d457e99a8d..8c7aa6e6fff42 100644
--- a/packages/backend/src/ee/services/AiService/utils/errors.ts
+++ b/packages/backend/src/ee/services/AiService/utils/errors.ts
@@ -11,3 +11,10 @@ export class AiSlackMappingNotFoundError extends Error {
         this.name = 'AiSlackMappingNotFoundError';
     }
 }
+
+export class AiAgentNotFoundError extends Error {
+    constructor(message: string) {
+        super(message);
+        this.name = 'AiAgentNotFoundError';
+    }
+}
diff --git a/packages/backend/src/models/ModelRepository.ts b/packages/backend/src/models/ModelRepository.ts
index 7ca3447780667..f9393b59ae868 100644
--- a/packages/backend/src/models/ModelRepository.ts
+++ b/packages/backend/src/models/ModelRepository.ts
@@ -95,6 +95,7 @@ export type ModelManifest = {
     resultsFileModel: ResultsFileModel;
     /** An implementation signature for these models are not available at this stage */
     aiModel: unknown;
+    aiAgentModel: unknown;
     embedModel: unknown;
     dashboardSummaryModel: unknown;
     scimOrganizationAccessTokenModel: unknown;
@@ -521,6 +522,10 @@ export class ModelRepository
         return this.getModel('aiModel');
     }
 
+    public getAiAgentModel<ModelImplT>(): ModelImplT {
+        return this.getModel('aiAgentModel');
+    }
+
     public getEmbedModel<ModelImplT>(): ModelImplT {
         return this.getModel('embedModel');
     }
diff --git a/packages/common/src/ee/Ai/types.ts b/packages/common/src/ee/Ai/types.ts
index 233804dd0ed5e..9f9bbf7e69ca4 100644
--- a/packages/common/src/ee/Ai/types.ts
+++ b/packages/common/src/ee/Ai/types.ts
@@ -7,6 +7,7 @@ export type AiThread = {
     projectUuid: string;
     createdAt: Date;
     createdFrom: string;
+    agentUuid: string | null;
 };
 
 export type CreateSlackThread = {
@@ -16,6 +17,7 @@ export type CreateSlackThread = {
     slackUserId: string;
     slackChannelId: string;
     slackThreadTs: string;
+    agentUuid: string | null;
 };
 
 export type CreateWebAppThread = {
@@ -23,11 +25,13 @@ export type CreateWebAppThread = {
     projectUuid: string;
     userUuid: string;
     createdFrom: 'web_app';
+    agentUuid: string | null;
 };
 
 export type AiPrompt = {
     organizationUuid: string;
     projectUuid: string;
+    agentUuid: string | null;
     promptUuid: string;
     threadUuid: string;
     createdByUserUuid: string;
diff --git a/packages/common/src/ee/AiAgent/index.ts b/packages/common/src/ee/AiAgent/index.ts
index be52f48c547a7..562a153cac3fa 100644
--- a/packages/common/src/ee/AiAgent/index.ts
+++ b/packages/common/src/ee/AiAgent/index.ts
@@ -11,10 +11,7 @@ export type BaseAiAgent = {
     description: string;
     imageUrl: string;
 
-    dataSources: {
-        // null means all tags are available
-        fieldNameTags: string[] | null; // slack_project_mappings.available_tags
-    };
+    tags: string[] | null;
 
     integrations: {
         type: AiAgentIntegrationType;
@@ -22,7 +19,6 @@ export type BaseAiAgent = {
     }[];
 
     createdAt: string;
-    updatedAt: string;
 
     instructions: string | null;
     provider: 'openai';
@@ -31,12 +27,12 @@ export type BaseAiAgent = {
 
 export type AiAgent = Pick<
     BaseAiAgent,
-    'uuid' | 'projectUuid' | 'organizationUuid' | 'integrations' | 'dataSources'
+    'uuid' | 'projectUuid' | 'organizationUuid' | 'integrations' | 'tags'
 >;
 
 export type AiAgentSummary = Pick<
     AiAgent,
-    'uuid' | 'projectUuid' | 'organizationUuid' | 'integrations' | 'dataSources'
+    'uuid' | 'projectUuid' | 'organizationUuid' | 'integrations' | 'tags'
 >;
 
 export type AiAgentMessage =
@@ -93,12 +89,12 @@ export type ApiAiAgentSummaryResponse = {
 
 export type ApiCreateAiAgent = Pick<
     AiAgent,
-    'projectUuid' | 'integrations' | 'dataSources'
+    'projectUuid' | 'integrations' | 'tags'
 >;
 
 export type ApiUpdateAiAgent = {
     uuid: AiAgent['uuid'];
-    dataSources?: AiAgent['dataSources'];
+    tags?: AiAgent['tags'];
     integrations?: AiAgent['integrations'];
 };
 


Reviews:
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T17:34:37Z
user: login: joaoviana
body: looking great, some thoughts 🙏 
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T09:35:59Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T08:50:29Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:32:28Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:33:12Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:43:48Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:48:15Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:49:22Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:02:08Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:24:00Z
user: login: IrakliJani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:24:48Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:29:38Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T15:30:35Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:04:01Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:05:00Z
user: login: notgiorgi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:10:28Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:15:53Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T16:35:14Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T09:08:36Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T10:59:33Z
user: login: IrakliJani
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:27:11Z
user: login: IrakliJani
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T11:28:26Z

Review comments:
diff_hunk: @@ -0,0 +1,30 @@
+import { Knex } from 'knex';
+
+export const AiAgentTableName = 'ai_agent';
+
+export type DbAiAgent = {
+    ai_agent_uuid: string;
+    organization_uuid: string;
+    project_uuid: string;
+    name: string;
+    description: string;
+    image_url: string;
+    data_sources: object;
+
+    created_at: Date;
+};
path: packages/backend/src/ee/database/entities/aiAgent.ts
user: login: graphite-app[bot]
body: The `DbAiAgent` type definition is missing the `updated_at` field that's included in the database migration. The migration file creates this column at line 33-36:

```ts
table
    .timestamp('updated_at', { useTz: false })
    .notNullable()
    .defaultTo(knex.fn.now());
```

Please add `updated_at: Date;` to the type definition to ensure type safety and maintain consistency between the code and database schema.
```suggestion

export type DbAiAgent = {
    ai_agent_uuid: string;
    organization_uuid: string;
    project_uuid: string;
    name: string;
    description: string;
    image_url: string;
    data_sources: object;

    created_at: Date;
    updated_at: Date;
};

```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-16T17:34:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+import { Knex } from 'knex';
+
+export const AiAgentTableName = 'ai_agent';
+
+export type DbAiAgent = {
+    ai_agent_uuid: string;
+    organization_uuid: string;
+    project_uuid: string;
+    name: string;
+    description: string;
+    image_url: string;
+    data_sources: object;
+
+    created_at: Date;
path: packages/backend/src/ee/database/entities/aiAgent.ts
user: login: joaoviana
body: ```suggestion
    ai_agent_uuid: string;
    organization_uuid: string;
    project_uuid: string;
    name: string;
    description: string  | null;
    image_url: string  | null;
    data_sources: object | null;

    created_at: Date;
```
created_at: 2025-05-19T09:19:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,30 @@
+import { Knex } from 'knex';
+
+export const AiAgentTableName = 'ai_agent';
+
+export type DbAiAgent = {
+    ai_agent_uuid: string;
+    organization_uuid: string;
+    project_uuid: string;
+    name: string;
+    description: string;
+    image_url: string;
+    data_sources: object;
+
+    created_at: Date;
path: packages/backend/src/ee/database/entities/aiAgent.ts
user: login: joaoviana
body: I think some of these types need to be nullable, wdyt?
created_at: 2025-05-19T09:20:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,27 @@
+import { Knex } from 'knex';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable('ai_agent_integration')) return;
path: packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts
user: login: joaoviana
body: ```suggestion
    if (await knex.schema.hasTable(AiAgentIntegrationTableName)) return;
```

would be cool to import these types of constants from `entities/aiAgent.ts`
created_at: 2025-05-19T09:21:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,49 @@
+import { Knex } from 'knex';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable('ai_agent')) return;
+
+    await knex.schema.createTable('ai_agent', (table) => {
+        table
+            .uuid('ai_agent_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+
+        table
+            .uuid('project_uuid')
+            .notNullable()
+            .references('project_uuid')
+            .inTable('projects')
+            .onDelete('CASCADE');
+        table
+            .uuid('organization_uuid')
+            .notNullable()
+            .references('organization_uuid')
+            .inTable('organizations')
+            .onDelete('CASCADE');
+
+        table.string('name').notNullable();
+        table.text('description');
+        table.text('image_url');
+        table.jsonb('data_sources').defaultTo({ fieldNameTags: null });
path: packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
user: login: joaoviana
body: not sure if we should have a default for this... maybe just `null` for now?
created_at: 2025-05-19T09:28:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,49 @@
+import { Knex } from 'knex';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable('ai_agent')) return;
+
+    await knex.schema.createTable('ai_agent', (table) => {
+        table
+            .uuid('ai_agent_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+
+        table
+            .uuid('project_uuid')
+            .notNullable()
+            .references('project_uuid')
+            .inTable('projects')
+            .onDelete('CASCADE');
+        table
+            .uuid('organization_uuid')
+            .notNullable()
+            .references('organization_uuid')
+            .inTable('organizations')
+            .onDelete('CASCADE');
+
+        table.string('name').notNullable();
+        table.text('description');
+        table.text('image_url');
+        table.jsonb('data_sources').defaultTo({ fieldNameTags: null });
path: packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
user: login: joaoviana
body: Understood, so that the migration for slack channel <-> project gets set. 
created_at: 2025-05-19T09:29:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,63 @@
+// Migration 05: Migrate data from slack_channel_project_mappings to agents
+import { Knex } from 'knex';
+
+interface SlackMapping {
+    project_uuid: string;
+    organization_uuid: string;
+    slack_channel_id: string;
+    available_tags: string[] | null;
+}
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable('slack_channel_project_mappings'))) return;
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    // Process each mapping and create agents
+    // Sequential is fine because there're very few rows
+    for await (const mapping of slackMappings) {
+        try {
+            const [agent] = await knex('ai_agent')
+                .insert({
+                    project_uuid: mapping.project_uuid,
+                    organization_uuid: mapping.organization_uuid,
+                    name: `Legacy Agent`,
+                    description: `Legacy agent for channel ${mapping.slack_channel_project_mapping_uuid}`,
+                    data_sources: {
+                        fieldNameTags: mapping.available_tags,
+                    },
+                })
+                .returning('ai_agent_uuid');
+
+            await knex('ai_agent_integration').insert({
+                ai_agent_uuid: agent.ai_agent_uuid,
+                integration_type: 'slack',
+                configuration: {
+                    channelId: mapping.slack_channel_id,
+                },
+            });
+        } catch (error) {
+            console.error(
+                'ERROR_20250516163613_migrate_slack_channel_project_mappings_table',
+                error,
+            );
+            // We can ignore failed migrations, as they're not critical
+            // eslint-disable-next-line no-continue
+            continue;
+        }
+    }
+
+    // Note: We're not dropping the slack_channel_project_mappings table
+    // That would be done in a separate migration later, after verification
+}
+
+export async function down(knex: Knex): Promise<void> {
+    // No need for down
+    // We're not removing the agents table will be dropped by parent migrations
path: packages/backend/src/ee/database/migrations/20250516163613_migrate_slack_channel_project_mappings_table.ts
user: login: joaoviana
body: 👍 
created_at: 2025-05-19T09:32:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,63 @@
+// Migration 05: Migrate data from slack_channel_project_mappings to agents
+import { Knex } from 'knex';
+
+interface SlackMapping {
+    project_uuid: string;
+    organization_uuid: string;
+    slack_channel_id: string;
+    available_tags: string[] | null;
+}
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable('slack_channel_project_mappings'))) return;
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    // Process each mapping and create agents
+    // Sequential is fine because there're very few rows
+    for await (const mapping of slackMappings) {
+        try {
+            const [agent] = await knex('ai_agent')
+                .insert({
+                    project_uuid: mapping.project_uuid,
+                    organization_uuid: mapping.organization_uuid,
+                    name: `Legacy Agent`,
+                    description: `Legacy agent for channel ${mapping.slack_channel_project_mapping_uuid}`,
+                    data_sources: {
+                        fieldNameTags: mapping.available_tags,
+                    },
+                })
+                .returning('ai_agent_uuid');
+
+            await knex('ai_agent_integration').insert({
+                ai_agent_uuid: agent.ai_agent_uuid,
+                integration_type: 'slack',
+                configuration: {
+                    channelId: mapping.slack_channel_id,
+                },
+            });
+        } catch (error) {
+            console.error(
+                'ERROR_20250516163613_migrate_slack_channel_project_mappings_table',
+                error,
+            );
+            // We can ignore failed migrations, as they're not critical
+            // eslint-disable-next-line no-continue
+            continue;
+        }
+    }
+
+    // Note: We're not dropping the slack_channel_project_mappings table
+    // That would be done in a separate migration later, after verification
path: packages/backend/src/ee/database/migrations/20250516163613_migrate_slack_channel_project_mappings_table.ts
user: login: joaoviana
body: this can be removed as it feels like a development action plan rather than a NOTE on the code implemented
created_at: 2025-05-19T09:33:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,63 @@
+// Migration 05: Migrate data from slack_channel_project_mappings to agents
path: packages/backend/src/ee/database/migrations/20250516163613_migrate_slack_channel_project_mappings_table.ts
user: login: joaoviana
body: unnecessary comment
created_at: 2025-05-19T09:33:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,63 @@
+// Migration 05: Migrate data from slack_channel_project_mappings to agents
+import { Knex } from 'knex';
+
+interface SlackMapping {
+    project_uuid: string;
+    organization_uuid: string;
+    slack_channel_id: string;
+    available_tags: string[] | null;
+}
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable('slack_channel_project_mappings'))) return;
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    // Process each mapping and create agents
+    // Sequential is fine because there're very few rows
+    for await (const mapping of slackMappings) {
+        try {
+            const [agent] = await knex('ai_agent')
+                .insert({
+                    project_uuid: mapping.project_uuid,
+                    organization_uuid: mapping.organization_uuid,
+                    name: `Legacy Agent`,
path: packages/backend/src/ee/database/migrations/20250516163613_migrate_slack_channel_project_mappings_table.ts
user: login: joaoviana
body: I wouldn't call this `Legacy Agent` IMO. Users can build on top of this migrated content and start working on it.
Can you have multiple agents with the same name? So just "Agent"?

And description could be like 
`Agent for channel {channel name} for project {project name}` wdyt?
created_at: 2025-05-19T09:35:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,63 @@
+import { Knex } from 'knex';
+
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable(AIAgentIntegrationTableName)) return;
+    await knex.schema.createTable(AIAgentIntegrationTableName, (table) => {
+        table
+            .uuid('ai_agent_integration_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+        table
+            .uuid('ai_agent_uuid')
+            .references('ai_agent_uuid')
+            .inTable('ai_agent')
+            .onDelete('CASCADE')
+            .notNullable();
+        table.enum('integration_type', ['slack']).notNullable();
+        table
+            .timestamp('created_at', { useTz: false })
+            .notNullable()
+            .defaultTo(knex.fn.now());
+    });
+
+    if (await knex.schema.hasTable(AIAgentSlackIntegrationTableName)) return;
path: packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts
user: login: graphite-app[bot]
body: The early return at line 26 will exit the entire function if the Slack integration table exists, preventing the creation of the main integration table. These should be separate conditional blocks to ensure both table creation steps are properly evaluated. Consider refactoring to:

```typescript
if (!(await knex.schema.hasTable(AIAgentIntegrationTableName))) {
    await knex.schema.createTable(AIAgentIntegrationTableName, (table) => {
        // existing implementation
    });
}

if (!(await knex.schema.hasTable(AIAgentSlackIntegrationTableName))) {
    await knex.schema.createTable(AIAgentSlackIntegrationTableName, (table) => {
        // existing implementation
    });
}
```

This ensures both tables are created independently when needed.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T08:50:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,51 @@
+import { Knex } from 'knex';
+
+const AIAgentTableName = 'ai_agent';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable(AIAgentTableName)) return;
+
+    await knex.schema.createTable(AIAgentTableName, (table) => {
+        table
+            .uuid('ai_agent_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+
+        table
+            .uuid('project_uuid')
+            .notNullable()
+            .references('project_uuid')
+            .inTable('projects')
+            .onDelete('CASCADE');
+        table
+            .uuid('organization_uuid')
+            .notNullable()
+            .references('organization_uuid')
+            .inTable('organizations')
+            .onDelete('CASCADE');
+
+        table.string('name').notNullable();
+        table.text('description');
+        table.text('image_url');
+        table.jsonb('data_sources').defaultTo({ fieldNameTags: null });
path: packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
user: login: IrakliJani
body: This should ideally be avoided, as it’s equivalent to using `any` in TS and makes data migrations difficult. Based on our recent conversation with the customers, this can be either NULL (meaning all explores and fields are available) or an nonempty array of tags that will filter both explores and fields based on the tags.
created_at: 2025-05-20T14:32:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,51 @@
+import { Knex } from 'knex';
+
+const AIAgentTableName = 'ai_agent';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable(AIAgentTableName)) return;
+
+    await knex.schema.createTable(AIAgentTableName, (table) => {
+        table
+            .uuid('ai_agent_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+
+        table
+            .uuid('project_uuid')
+            .notNullable()
+            .references('project_uuid')
+            .inTable('projects')
+            .onDelete('CASCADE');
+        table
+            .uuid('organization_uuid')
+            .notNullable()
+            .references('organization_uuid')
+            .inTable('organizations')
+            .onDelete('CASCADE');
+
+        table.string('name').notNullable();
+        table.text('description');
+        table.text('image_url');
+        table.jsonb('data_sources').defaultTo({ fieldNameTags: null });
+        table
+            .timestamp('created_at', { useTz: false })
+            .notNullable()
+            .defaultTo(knex.fn.now());
+        table
+            .timestamp('updated_at', { useTz: false })
+            .notNullable()
+            .defaultTo(knex.fn.now());
path: packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
user: login: IrakliJani
body: This requires manually updating the timestamp every time a row is updated.
created_at: 2025-05-20T14:33:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,51 @@
+import { Knex } from 'knex';
+
+const AIAgentTableName = 'ai_agent';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable(AIAgentTableName)) return;
+
+    await knex.schema.createTable(AIAgentTableName, (table) => {
+        table
+            .uuid('ai_agent_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+
+        table
+            .uuid('project_uuid')
+            .notNullable()
+            .references('project_uuid')
+            .inTable('projects')
+            .onDelete('CASCADE');
+        table
+            .uuid('organization_uuid')
+            .notNullable()
+            .references('organization_uuid')
+            .inTable('organizations')
+            .onDelete('CASCADE');
+
+        table.string('name').notNullable();
+        table.text('description');
+        table.text('image_url');
+        table.jsonb('data_sources').defaultTo({ fieldNameTags: null });
+        table
+            .timestamp('created_at', { useTz: false })
+            .notNullable()
+            .defaultTo(knex.fn.now());
+        table
+            .timestamp('updated_at', { useTz: false })
+            .notNullable()
+            .defaultTo(knex.fn.now());
+
+        // Indexes
+        table.index(
+            ['project_uuid', 'organization_uuid'],
+            'idx_ai_agent_project_org',
+        );
path: packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
user: login: IrakliJani
body: scrap this
created_at: 2025-05-20T14:43:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Knex } from 'knex';
+
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(AIAgentIntegrationTableName))) {
+        await knex.schema.createTable(AIAgentIntegrationTableName, (table) => {
+            table
+                .uuid('ai_agent_integration_uuid')
+                .primary()
+                .defaultTo(knex.raw('uuid_generate_v4()'));
+            table
+                .uuid('ai_agent_uuid')
+                .references('ai_agent_uuid')
+                .inTable('ai_agent')
+                .onDelete('CASCADE')
+                .notNullable();
+            table.enum('integration_type', ['slack']).notNullable();
+            table
+                .timestamp('created_at', { useTz: false })
+                .notNullable()
+                .defaultTo(knex.fn.now());
+        });
+    }
+
+    if (!(await knex.schema.hasTable(AIAgentSlackIntegrationTableName))) {
+        await knex.schema.createTable(
+            AIAgentSlackIntegrationTableName,
+            (table) => {
+                table
+                    .uuid('ai_agent_integration_slack_uuid')
+                    .primary()
+                    .defaultTo(knex.raw('uuid_generate_v4()'));
+                table
+                    .uuid('organization_uuid')
+                    .notNullable()
+                    .references('organization_uuid')
+                    .inTable('organizations')
+                    .onDelete('CASCADE');
+                table
+                    .uuid('ai_agent_integration_uuid')
+                    .notNullable()
+                    .unique()
path: packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts
user: login: IrakliJani
body: I don't get this
created_at: 2025-05-20T14:48:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Knex } from 'knex';
+
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(AIAgentIntegrationTableName))) {
+        await knex.schema.createTable(AIAgentIntegrationTableName, (table) => {
+            table
+                .uuid('ai_agent_integration_uuid')
+                .primary()
+                .defaultTo(knex.raw('uuid_generate_v4()'));
+            table
+                .uuid('ai_agent_uuid')
+                .references('ai_agent_uuid')
+                .inTable('ai_agent')
+                .onDelete('CASCADE')
+                .notNullable();
+            table.enum('integration_type', ['slack']).notNullable();
+            table
+                .timestamp('created_at', { useTz: false })
+                .notNullable()
+                .defaultTo(knex.fn.now());
+        });
+    }
+
+    if (!(await knex.schema.hasTable(AIAgentSlackIntegrationTableName))) {
+        await knex.schema.createTable(
+            AIAgentSlackIntegrationTableName,
+            (table) => {
+                table
+                    .uuid('ai_agent_integration_slack_uuid')
+                    .primary()
+                    .defaultTo(knex.raw('uuid_generate_v4()'));
+                table
+                    .uuid('organization_uuid')
+                    .notNullable()
+                    .references('organization_uuid')
+                    .inTable('organizations')
+                    .onDelete('CASCADE');
+                table
+                    .uuid('ai_agent_integration_uuid')
+                    .notNullable()
+                    .unique()
+                    .references('ai_agent_integration_uuid')
+                    .inTable('ai_agent_integration')
path: packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts
user: login: IrakliJani
body: can be a variable - declared above
created_at: 2025-05-20T14:49:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Knex } from 'knex';
+
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(AIAgentIntegrationTableName))) {
+        await knex.schema.createTable(AIAgentIntegrationTableName, (table) => {
+            table
+                .uuid('ai_agent_integration_uuid')
+                .primary()
+                .defaultTo(knex.raw('uuid_generate_v4()'));
+            table
+                .uuid('ai_agent_uuid')
+                .references('ai_agent_uuid')
+                .inTable('ai_agent')
+                .onDelete('CASCADE')
+                .notNullable();
+            table.enum('integration_type', ['slack']).notNullable();
+            table
+                .timestamp('created_at', { useTz: false })
+                .notNullable()
+                .defaultTo(knex.fn.now());
+        });
path: packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts
user: login: IrakliJani
body: Should agent uuid and integration type be unique?
created_at: 2025-05-20T15:02:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,25 @@
+import { Knex } from 'knex';
+
+const AITableName = 'ai_thread';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasColumn(AITableName, 'agent_uuid')) return;
+
+    await knex.schema.alterTable(AITableName, (table) => {
+        table
+            .uuid('agent_uuid')
+            .nullable()
+            .references('ai_agent_uuid')
+            .inTable('ai_agent')
+            .onDelete('SET NULL');
path: packages/backend/src/ee/database/migrations/20250516163327_add_ai_agent_uuid_to_ai_thread.ts
user: login: IrakliJani
body: I think this migration should be part of the data migration
created_at: 2025-05-20T15:23:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,64 @@
+import { Knex } from 'knex';
+
+const SlackChannelProjectMappingsTableName = 'slack_channel_project_mappings';
+const AIAgentTableName = 'ai_agent';
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(SlackChannelProjectMappingsTableName)))
+        return;
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    for await (const mapping of slackMappings) {
+        try {
+            await knex.transaction(async (trx) => {
+                const [agent] = await trx(AIAgentTableName)
+                    .insert({
+                        project_uuid: mapping.project_uuid,
+                        organization_uuid: mapping.organization_uuid,
+                        name: '',
+                        description: '',
+                        data_sources: {
+                            fieldNameTags: mapping.available_tags,
+                        },
+                    })
+                    .returning('ai_agent_uuid');
+
+                const [integration] = await trx(AIAgentIntegrationTableName)
+                    .insert({
+                        ai_agent_uuid: agent.ai_agent_uuid,
+                        integration_type: 'slack',
+                    })
+                    .returning('ai_agent_integration_uuid');
+
+                await trx(AIAgentSlackIntegrationTableName).insert({
+                    ai_agent_integration_uuid:
+                        integration.ai_agent_integration_uuid,
+                    organization_uuid: mapping.organization_uuid,
+                    slack_channel_id: mapping.slack_channel_id,
+                });
+            });
path: packages/backend/src/ee/database/migrations/20250516163613_migrate_slack_channel_project_mappings_table.ts
user: login: IrakliJani
body: remove nullable from `agent_uuid`
created_at: 2025-05-20T15:24:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+import { Knex } from 'knex';
+
+const AIAgentThreadsTableName = 'ai_thread';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasColumn(AIAgentThreadsTableName, 'agent_uuid')))
+        return;
+
+    await knex.schema.alterTable(AIAgentThreadsTableName, (table) => {
+        table.uuid('agent_uuid').notNullable().alter();
+    });
+}
path: packages/backend/src/ee/database/migrations/20250520152323_alter_ai_agent_threads_agent_uuid_not_nullable.ts
user: login: graphite-app[bot]
body: This migration could fail if there are existing records with `null` values in the `agent_uuid` column. Before making the column `NOT NULL`, consider either:

1. Adding a validation step to ensure all records have a valid `agent_uuid`
2. Providing a default value for null entries
3. Using a transaction that first updates any null values and then alters the column

This is particularly important since the previous migration (20250516163613) might not have populated all records if some threads weren't associated with Slack channels.
```suggestion

export async function up(knex: Knex): Promise<void> {
    if (!(await knex.schema.hasColumn(AIAgentThreadsTableName, 'agent_uuid')))
        return;

    // Use a transaction to ensure both operations succeed or fail together
    await knex.transaction(async (trx) => {
        // First, delete any records with null agent_uuid
        // Alternative approach could be to set a default value instead of deleting
        await trx(AIAgentThreadsTableName)
            .whereNull('agent_uuid')
            .delete();
        
        // Then alter the column to be NOT NULL
        await trx.schema.alterTable(AIAgentThreadsTableName, (table) => {
            table.uuid('agent_uuid').notNullable().alter();
        });
    });
}

```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T15:29:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,70 @@
+/* eslint-disable no-continue */
+import { Knex } from 'knex';
+
+const SlackChannelProjectMappingsTableName = 'slack_channel_project_mappings';
+const AIAgentTableName = 'ai_agent';
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(SlackChannelProjectMappingsTableName)))
+        return;
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    for await (const mapping of slackMappings) {
+        const [agent] = await knex(AIAgentTableName)
+            .insert({
+                project_uuid: mapping.project_uuid,
+                organization_uuid: mapping.organization_uuid,
+                name: '',
+                description: '',
+                data_sources: {
+                    fieldNameTags: mapping.available_tags,
+                },
+            })
+            .returning('ai_agent_uuid');
+
+        const [integration] = await knex(AIAgentIntegrationTableName)
+            .insert({
+                ai_agent_uuid: agent.ai_agent_uuid,
+                integration_type: 'slack',
+            })
+            .returning('ai_agent_integration_uuid');
+
+        await knex(AIAgentSlackIntegrationTableName).insert({
+            ai_agent_integration_uuid: integration.ai_agent_integration_uuid,
+            organization_uuid: mapping.organization_uuid,
+            slack_channel_id: mapping.slack_channel_id,
+        });
+
+        await knex.raw(
+            `
+                        UPDATE ai_thread t
+                        SET
+                            agent_uuid = ?
+                        FROM
+                            ai_slack_thread st
+                        WHERE
+                            st.ai_thread_uuid = t.ai_thread_uuid
+                            AND t.organization_uuid = ?
+                            AND st.slack_channel_id = ?`,
+            [
+                agent.ai_agent_uuid,
+                mapping.organization_uuid,
+                mapping.slack_channel_id,
+            ],
+        );
+    }
+}
+
+export async function down(knex: Knex): Promise<void> {
+    // No need for down
+    // We're not removing the agents table will be dropped by parent migrations
+}
path: packages/backend/src/ee/database/migrations/20250516163613_migrate_slack_channel_project_mappings_table.ts
user: login: graphite-app[bot]
body: The `down` migration is incomplete as it doesn't revert the data changes made in the `up` migration. When `agent_uuid` values were set in the `ai_thread` table, these relationships should be cleared during rollback. Consider adding code to reset these values:

```sql
await knex('ai_thread')
  .whereNotNull('agent_uuid')
  .update({ agent_uuid: null });
```

This ensures a complete rollback of all changes, not just schema changes. The subsequent migration that makes `agent_uuid` non-nullable would need to be rolled back first, which appears to be handled correctly in its own `down` function.
```suggestion

export async function down(knex: Knex): Promise<void> {
    // Reset agent_uuid values to null in ai_thread table
    await knex('ai_thread')
        .whereNotNull('agent_uuid')
        .update({ agent_uuid: null });
}

```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T15:29:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,21 @@
+import { Knex } from 'knex';
+
+const AIAgentThreadsTableName = 'ai_thread';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasColumn(AIAgentThreadsTableName, 'agent_uuid')))
+        return;
+
+    await knex.schema.alterTable(AIAgentThreadsTableName, (table) => {
+        table.uuid('agent_uuid').notNullable().alter();
+    });
path: packages/backend/src/ee/database/migrations/20250520152323_alter_ai_agent_threads_agent_uuid_not_nullable.ts
user: login: graphite-app[bot]
body: This migration attempts to make the agent_uuid column non-nullable without first ensuring all existing records have a value. Before altering the column, you should add code to handle existing NULL values by either: 1) assigning them to a default agent, 2) deleting records with NULL values, or 3) implementing a business-logic appropriate solution. For example, add code to check for NULL values and update them before making the column non-nullable.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T15:30:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,41 @@
+import { Knex } from 'knex';
+
+const AIAgentTableName = 'ai_agent';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable(AIAgentTableName)) return;
+
+    await knex.schema.createTable(AIAgentTableName, (table) => {
+        table
+            .uuid('ai_agent_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+
+        table
+            .uuid('project_uuid')
+            .notNullable()
+            .references('project_uuid')
+            .inTable('projects')
+            .onDelete('CASCADE');
+        table
+            .uuid('organization_uuid')
+            .notNullable()
+            .references('organization_uuid')
+            .inTable('organizations')
+            .onDelete('CASCADE');
+
+        table.string('name').notNullable();
+        table.text('description');
+        table.text('image_url');
+        table.specificType('available_tags', 'text[]').defaultTo(null);
+        table
+            .timestamp('created_at', { useTz: false })
+            .notNullable()
+            .defaultTo(knex.fn.now());
+    });
path: packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
user: login: graphite-app[bot]
body: The entity type `DbAiAgent` includes an `updated_at` field, but it's missing from the table creation migration. Consider adding an `updated_at` timestamp column to the migration to maintain consistency between the entity definition and database schema:

```sql
table
    .timestamp('updated_at', { useTz: false })
    .notNullable()
    .defaultTo(knex.fn.now());
```

This will ensure the database structure properly reflects the entity model and prevent potential issues when accessing this field in the application code.
```suggestion

        table
            .timestamp('created_at', { useTz: false })
            .notNullable()
            .defaultTo(knex.fn.now());
            
        table
            .timestamp('updated_at', { useTz: false })
            .notNullable()
            .defaultTo(knex.fn.now());
    });

```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T16:04:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+import { Knex } from 'knex';
+
+export const AiAgentTableName = 'ai_agent';
+
+export type DbAiAgent = {
+    ai_agent_uuid: string;
+    organization_uuid: string;
+    project_uuid: string;
+    name: string;
+    description: string;
+    image_url: string;
+    data_sources: object;
path: packages/backend/src/ee/database/entities/aiAgent.ts
user: login: graphite-app[bot]
body: The 'data_sources' field in the entity definition doesn't match any column in the database migration. Either add this column to the migration or replace it with 'available_tags' to match the column defined in the migration file.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T16:04:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,43 @@
+import { Knex } from 'knex';
+
+export const AiAgentTableName = 'ai_agent';
+
+export type DbAiAgent = {
+    ai_agent_uuid: string;
+    organization_uuid: string;
+    project_uuid: string;
+    name: string;
+    description: string;
+    image_url: string;
+    data_sources: object;
+
+    created_at: Date;
+    updated_at: Date;
+};
path: packages/backend/src/ee/database/entities/aiAgent.ts
user: login: graphite-app[bot]
body: The DbAiAgent type is missing the 'available_tags' field which is defined in the migration. Add 'available_tags: string[];' to the type definition to match the database schema.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T16:04:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,68 @@
+import { Knex } from 'knex';
+
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(AIAgentIntegrationTableName))) {
+        await knex.schema.createTable(AIAgentIntegrationTableName, (table) => {
+            table
+                .uuid('ai_agent_integration_uuid')
+                .primary()
+                .defaultTo(knex.raw('uuid_generate_v4()'));
+            table
+                .uuid('ai_agent_uuid')
+                .references('ai_agent_uuid')
+                .inTable('ai_agent')
+                .onDelete('CASCADE')
+                .notNullable();
+            table.enum('integration_type', ['slack']).notNullable();
+            table
+                .timestamp('created_at', { useTz: false })
+                .notNullable()
+                .defaultTo(knex.fn.now());
+        });
path: packages/backend/src/ee/database/migrations/20250516162927_add_ai_agent_integrations_table.ts
user: login: notgiorgi
body: each agent can have multiple integrations. Each integration is a single slack channel in this case
created_at: 2025-05-20T16:10:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+import { Knex } from 'knex';
+
+const AIThreadTableName = 'ai_thread';
+const AIAgentTableName = 'ai_agent';
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+const AIAgentThreadsTableName = 'ai_thread';
+
+export async function up(knex: Knex): Promise<void> {
+    await knex.schema.alterTable(AIThreadTableName, (table) => {
+        table
+            .uuid('agent_uuid')
+            .nullable()
+            .references('ai_agent_uuid')
+            .inTable('ai_agent')
+            .onDelete('SET NULL');
+    });
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    for await (const mapping of slackMappings) {
+        const [agent] = await knex(AIAgentTableName)
+            .insert({
+                project_uuid: mapping.project_uuid,
+                organization_uuid: mapping.organization_uuid,
+                name: '',
+                description: '',
+                image_url: '',
+                available_tags: mapping.available_tags,
+            })
+            .returning('ai_agent_uuid');
+
+        const [integration] = await knex(AIAgentIntegrationTableName)
+            .insert({
+                ai_agent_uuid: agent.ai_agent_uuid,
+                integration_type: 'slack',
+            })
+            .returning('ai_agent_integration_uuid');
+
+        await knex(AIAgentSlackIntegrationTableName).insert({
+            ai_agent_integration_uuid: integration.ai_agent_integration_uuid,
+            organization_uuid: mapping.organization_uuid,
+            slack_channel_id: mapping.slack_channel_id,
+        });
+
+        await knex.raw(
+            `
+                UPDATE ai_thread t
+                SET
+                    agent_uuid = ?
+                FROM
+                    ai_slack_thread st
+                WHERE
+                    st.ai_thread_uuid = t.ai_thread_uuid
+                    AND t.organization_uuid = ?
+                    AND st.slack_channel_id = ?`,
+            [
+                agent.ai_agent_uuid,
+                mapping.organization_uuid,
+                mapping.slack_channel_id,
+            ],
+        );
+    }
+
+    await knex.schema.alterTable(AIAgentThreadsTableName, (table) => {
+        table.uuid('agent_uuid').notNullable().alter();
+    });
path: packages/backend/src/ee/database/migrations/20250516163327_add_ai_agent_uuid_to_ai_thread.ts
user: login: graphite-app[bot]
body: The migration alters `agent_uuid` to be `NOT NULL` without verifying that all existing records have been assigned a value. This could cause the migration to fail if there are any threads not associated with Slack channels, as these wouldn't have received an agent assignment in the previous migration steps. 

Consider adding a safety check before this alteration:

```sql
SELECT COUNT(*) FROM ai_thread WHERE agent_uuid IS NULL;
```

If the count is non-zero, either:
1. Assign these threads to a default agent
2. Skip making the column `NOT NULL` and handle nullability in application code
3. Create a separate migration for making the column `NOT NULL` after ensuring all data is properly migrated

This ensures the migration will be robust against all possible data states.
```suggestion

// First check if there are any records with NULL agent_uuid
const nullAgentThreads = await knex(AIAgentThreadsTableName)
    .whereNull('agent_uuid')
    .count('* as count')
    .first();

if (nullAgentThreads && nullAgentThreads.count > 0) {
    // Assign a default agent UUID to any threads with NULL agent_uuid
    // You may want to use a specific default agent UUID based on your application logic
    const defaultAgentUuid = '00000000-0000-0000-0000-000000000000'; // Example default UUID
    await knex(AIAgentThreadsTableName)
        .whereNull('agent_uuid')
        .update({ agent_uuid: defaultAgentUuid });
}

// Now it's safe to alter the column to NOT NULL
await knex.schema.alterTable(AIAgentThreadsTableName, (table) => {
    table.uuid('agent_uuid').notNullable().alter();
});

```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T16:15:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,81 @@
+import { Knex } from 'knex';
+
+const AIThreadTableName = 'ai_thread';
+const AIAgentTableName = 'ai_agent';
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+const AIAgentThreadsTableName = 'ai_thread';
+
+export async function up(knex: Knex): Promise<void> {
+    await knex.schema.alterTable(AIThreadTableName, (table) => {
+        table
+            .uuid('agent_uuid')
+            .nullable()
+            .references('ai_agent_uuid')
+            .inTable('ai_agent')
+            .onDelete('SET NULL');
+    });
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    for await (const mapping of slackMappings) {
+        const [agent] = await knex(AIAgentTableName)
+            .insert({
+                project_uuid: mapping.project_uuid,
+                organization_uuid: mapping.organization_uuid,
+                name: '',
+                description: '',
+                image_url: '',
+                tags: mapping.available_tags,
+            })
+            .returning('ai_agent_uuid');
+
+        const [integration] = await knex(AIAgentIntegrationTableName)
+            .insert({
+                ai_agent_uuid: agent.ai_agent_uuid,
+                integration_type: 'slack',
+            })
+            .returning('ai_agent_integration_uuid');
+
+        await knex(AIAgentSlackIntegrationTableName).insert({
+            ai_agent_integration_uuid: integration.ai_agent_integration_uuid,
+            organization_uuid: mapping.organization_uuid,
+            slack_channel_id: mapping.slack_channel_id,
+        });
+
+        await knex.raw(
+            `
+                UPDATE ai_thread t
+                SET
+                    agent_uuid = ?
+                FROM
+                    ai_slack_thread st
+                WHERE
+                    st.ai_thread_uuid = t.ai_thread_uuid
+                    AND t.organization_uuid = ?
+                    AND st.slack_channel_id = ?`,
+            [
+                agent.ai_agent_uuid,
+                mapping.organization_uuid,
+                mapping.slack_channel_id,
+            ],
+        );
+    }
+
+    await knex.schema.alterTable(AIAgentThreadsTableName, (table) => {
+        table.uuid('agent_uuid').notNullable().alter();
+    });
path: packages/backend/src/ee/database/migrations/20250516163327_add_ai_agent_uuid_to_ai_thread.ts
user: login: graphite-app[bot]
body: This code is attempting to make the agent_uuid column non-nullable, but there might be existing records without an agent_uuid value. This would cause the migration to fail. Either remove this alteration or add a check to ensure all records have a value before making the column non-nullable.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-20T16:35:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,41 @@
+import { Knex } from 'knex';
+
+const AIAgentTableName = 'ai_agent';
+
+export async function up(knex: Knex): Promise<void> {
+    if (await knex.schema.hasTable(AIAgentTableName)) return;
+
+    await knex.schema.createTable(AIAgentTableName, (table) => {
+        table
+            .uuid('ai_agent_uuid')
+            .primary()
+            .defaultTo(knex.raw('uuid_generate_v4()'));
+
+        table
+            .uuid('project_uuid')
+            .notNullable()
+            .references('project_uuid')
+            .inTable('projects')
+            .onDelete('CASCADE');
+        table
+            .uuid('organization_uuid')
+            .notNullable()
+            .references('organization_uuid')
+            .inTable('organizations')
+            .onDelete('CASCADE');
+
+        table.string('name').notNullable();
+        table.text('description');
+        table.text('image_url');
+        table.specificType('tags', 'text[]').defaultTo(null);
+        table
+            .timestamp('created_at', { useTz: false })
+            .notNullable()
+            .defaultTo(knex.fn.now());
+    });
+}
+
+export async function down(knex: Knex): Promise<void> {
+    if (!(await knex.schema.hasTable(AIAgentTableName))) return;
+    await knex.schema.dropTable(AIAgentTableName);
+}
path: packages/backend/src/ee/database/migrations/20250516162139_add_ai_agents_table.ts
user: login: graphite-app[bot]
body: This migration file uses a future date (2025-05-16) in its filename, which is causing issues with the migration system. Rename the file to use the current date format (YYYYMMDDHHMMSS) instead of a future date.

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-21T09:08:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+import { Knex } from 'knex';
+
+const AIThreadTableName = 'ai_thread';
+const AIAgentTableName = 'ai_agent';
+const AIAgentIntegrationTableName = 'ai_agent_integration';
+const AIAgentSlackIntegrationTableName = 'ai_agent_slack_integration';
+
+export async function up(knex: Knex): Promise<void> {
+    await knex.schema.alterTable(AIThreadTableName, (table) => {
+        table
+            .uuid('agent_uuid')
+            .nullable()
+            .references('ai_agent_uuid')
+            .inTable('ai_agent')
+            .onDelete('CASCADE');
+    });
+
+    const slackMappings = await knex('slack_channel_project_mappings').select(
+        'slack_channel_project_mapping_uuid',
+        'project_uuid',
+        'organization_uuid',
+        'slack_channel_id',
+        'available_tags',
+    );
+
+    for await (const mapping of slackMappings) {
+        const [agent] = await knex(AIAgentTableName)
+            .insert({
+                project_uuid: mapping.project_uuid,
+                organization_uuid: mapping.organization_uuid,
+                name: '',
+                description: '',
+                image_url: '',
+                tags: mapping.available_tags,
+            })
+            .returning('ai_agent_uuid');
+
+        const [integration] = await knex(AIAgentIntegrationTableName)
+            .insert({
+                ai_agent_uuid: agent.ai_agent_uuid,
+                integration_type: 'slack',
+            })
+            .returning('ai_agent_integration_uuid');
+
+        await knex(AIAgentSlackIntegrationTableName).insert({
+            ai_agent_integration_uuid: integration.ai_agent_integration_uuid,
+            organization_uuid: mapping.organization_uuid,
+            slack_channel_id: mapping.slack_channel_id,
+        });
+
+        await knex.raw(
+            `
+                UPDATE ai_thread t
+                SET
+                    agent_uuid = ?
+                FROM
+                    ai_slack_thread st
+                WHERE
+                    st.ai_thread_uuid = t.ai_thread_uuid
+                    AND t.organization_uuid = ?
+                    AND st.slack_channel_id = ?`,
+            [
+                agent.ai_agent_uuid,
+                mapping.organization_uuid,
+                mapping.slack_channel_id,
+            ],
+        );
+    }
path: packages/backend/src/ee/database/migrations/20250516163327_add_ai_agent_uuid_to_ai_thread.ts
user: login: graphite-app[bot]
body: This migration is likely failing because it's not wrapped in a transaction and has empty values for required fields. Modify the migration to: 1) Wrap the data migration in a transaction, 2) Provide meaningful default values for the 'name' field instead of empty strings, 3) Add error handling for each database operation, and 4) Consider renaming the migration file to use the current date instead of a future date (2025).

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=lightdash&ref=ai-review-comment) (based on CI logs)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-21T10:59:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: notgiorgi
created_at: 2025-05-16T17:31:26Z
body: * **#14862** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14862?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 2  dependent PRs ([#14884](https://github.com/lightdash/lightdash/pull/14884) <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14884?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>, [#14900](https://github.com/lightdash/lightdash/pull/14900) <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14900?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>) 👈 <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14862?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#14855** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14855?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: lightdash-bot
created_at: 2025-05-21T12:43:48Z
body: :tada: This PR is included in version 0.1639.0 :tada:

The release is available on:
- `0.1639.0`
- [GitHub release](https://github.com/lightdash/lightdash/releases/tag/0.1639.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: COLLABORATOR