AI tools to create a demo workspace (#18236)

This PR adds the necessary tool to create a demo workspace with :
relevant custom objects and fields, mock data and a real dashboard with
graph widgets.

It is still a bit under-optimized and slow but it works.

This PR also adds an AI tool that allows to see what happens in real
time, it navigates the app and waits when necessary.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Lucas Bordeau
2026-03-05 11:39:31 +01:00
committed by GitHub
parent a2f80d882b
commit 2a82df7073
84 changed files with 2449 additions and 315 deletions
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
@@ -279,15 +281,7 @@ ${otherPreloadedTools.length > 0 ? otherPreloadedTools.map((toolName) => `- \`${
### Tool Catalog by Category`);
const categoryOrder = [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.WORKFLOW,
ToolCategory.DASHBOARD,
ToolCategory.METADATA,
ToolCategory.VIEW,
ToolCategory.LOGIC_FUNCTION,
];
const categoryOrder = Object.values(ToolCategory);
for (const category of categoryOrder) {
const tools = toolsByCategory.get(category);
@@ -321,7 +315,7 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA
return sections.join('\n');
}
private getCategoryLabel(category: string): string {
private getCategoryLabel(category: ToolCategory): string {
switch (category) {
case ToolCategory.DATABASE_CRUD:
return 'Database Tools (CRUD operations)';
@@ -337,8 +331,12 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA
return 'Dashboard Tools (create/manage dashboards)';
case ToolCategory.LOGIC_FUNCTION:
return 'Logic Functions (custom tools)';
case ToolCategory.NATIVE_MODEL:
return 'Native Model Capabilities (e.g. web search)';
case ToolCategory.VIEW_FIELD:
return 'View Field Tools (manage view columns)';
default:
return category;
return assertUnreachable(category);
}
}
}
@@ -104,6 +104,22 @@ const DeleteFieldMetadataInputSchema = z.object({
id: z.string().uuid().describe('ID of the field to delete'),
});
const CreateManyFieldMetadataInputSchema = z.object({
fields: z
.array(CreateFieldMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of field metadata to create (1-20 items).'),
});
const UpdateManyFieldMetadataInputSchema = z.object({
fields: z
.array(UpdateFieldMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of field metadata updates to apply (1-20 items).'),
});
@Injectable()
export class FieldMetadataToolsFactory {
constructor(private readonly fieldMetadataService: FieldMetadataService) {}
@@ -229,6 +245,86 @@ export class FieldMetadataToolsFactory {
}
},
},
create_many_field_metadata: {
description:
'Create multiple field metadata at once on one or more objects. More efficient than calling create_field_metadata multiple times. Each item follows the same schema as create_field_metadata.',
inputSchema: CreateManyFieldMetadataInputSchema,
execute: async (parameters: {
fields: Array<{
objectMetadataId: string;
type: FieldMetadataType;
name: string;
label: string;
description?: string;
icon?: string;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
isRemoteCreation?: boolean;
relationCreationPayload?: unknown;
}>;
}) => {
try {
await this.fieldMetadataService.createManyFields({
createFieldInputs: parameters.fields as Parameters<
typeof this.fieldMetadataService.createManyFields
>[0]['createFieldInputs'],
workspaceId,
});
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_many_field_metadata: {
description:
'Update multiple field metadata at once. More efficient than calling update_field_metadata multiple times. Each item must include the field ID and the properties to update.',
inputSchema: UpdateManyFieldMetadataInputSchema,
execute: async (parameters: {
fields: Array<{
id: string;
name?: string;
label?: string;
description?: string;
icon?: string;
isActive?: boolean;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
}>;
}) => {
try {
await Promise.all(
parameters.fields.map(async ({ id, ...update }) => {
await this.fieldMetadataService.updateOneField({
updateFieldInput: { id, ...update } as Parameters<
typeof this.fieldMetadataService.updateOneField
>[0]['updateFieldInput'],
workspaceId,
});
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -81,6 +81,22 @@ const DeleteObjectMetadataInputSchema = z.object({
id: z.string().uuid().describe('ID of the object to delete'),
});
const CreateManyObjectMetadataInputSchema = z.object({
objects: z
.array(CreateObjectMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of object metadata to create (1-20 items).'),
});
const UpdateManyObjectMetadataInputSchema = z.object({
objects: z
.array(UpdateObjectMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of object metadata updates to apply (1-20 items).'),
});
@Injectable()
export class ObjectMetadataToolsFactory {
constructor(private readonly objectMetadataService: ObjectMetadataService) {}
@@ -202,6 +218,83 @@ export class ObjectMetadataToolsFactory {
}
},
},
create_many_object_metadata: {
description:
'Create multiple object metadata at once in the workspace data model. More efficient than calling create_object_metadata multiple times. Each item follows the same schema as create_object_metadata.',
inputSchema: CreateManyObjectMetadataInputSchema,
execute: async (parameters: {
objects: Array<{
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
shortcut?: string;
isRemote?: boolean;
isLabelSyncedWithName?: boolean;
}>;
}) => {
try {
await Promise.all(
parameters.objects.map(async (createObjectInput) => {
await this.objectMetadataService.createOneObject({
createObjectInput: createObjectInput as Parameters<
typeof this.objectMetadataService.createOneObject
>[0]['createObjectInput'],
workspaceId,
});
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_many_object_metadata: {
description:
'Update multiple object metadata at once. More efficient than calling update_object_metadata multiple times. Each item must include the object ID and the properties to update.',
inputSchema: UpdateManyObjectMetadataInputSchema,
execute: async (parameters: {
objects: Array<{
id: string;
labelSingular?: string;
labelPlural?: string;
nameSingular?: string;
namePlural?: string;
description?: string;
icon?: string;
shortcut?: string;
isActive?: boolean;
labelIdentifierFieldMetadataId?: string;
imageIdentifierFieldMetadataId?: string;
isLabelSyncedWithName?: boolean;
}>;
}) => {
try {
await Promise.all(
parameters.objects.map(async ({ id, ...update }) => {
await this.objectMetadataService.updateOneObject({
updateObjectInput: { id, update },
workspaceId,
});
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -0,0 +1,357 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { AggregateOperations } from 'twenty-shared/types';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const GetViewFieldsInputSchema = z.object({
viewId: z
.string()
.uuid()
.describe(
'The ID of the view to list fields for. Obtain this from get_views.',
),
});
const CreateViewFieldInputSchema = z.object({
viewId: z.string().uuid().describe('The ID of the view to add the field to.'),
fieldMetadataId: z
.string()
.uuid()
.describe(
'The ID of the field metadata to add. Use get_field_metadata to find available fields.',
),
isVisible: z
.boolean()
.optional()
.default(true)
.describe('Whether the field is visible in the view.'),
size: z
.number()
.int()
.optional()
.default(150)
.describe('Column width in pixels.'),
position: z
.number()
.optional()
.default(0)
.describe('Position of the field in the view (0-based).'),
aggregateOperation: z
.enum(Object.values(AggregateOperations) as [string, ...string[]])
.optional()
.describe(
'Aggregate operation for this field (e.g., "SUM", "AVG", "COUNT").',
),
});
const UpdateViewFieldInputSchema = z.object({
id: z
.string()
.uuid()
.describe(
'The ID of the view field to update. Obtain this from get_view_fields.',
),
isVisible: z.boolean().optional().describe('Whether the field is visible.'),
size: z.number().int().optional().describe('Column width in pixels.'),
position: z.number().optional().describe('Position of the field.'),
aggregateOperation: z
.enum(Object.values(AggregateOperations) as [string, ...string[]])
.optional()
.describe('Aggregate operation for this field.'),
});
const DeleteViewFieldInputSchema = z.object({
id: z
.string()
.uuid()
.describe(
'The ID of the view field to delete. Obtain this from get_view_fields.',
),
});
const CreateManyViewFieldsInputSchema = z.object({
viewFields: z
.array(CreateViewFieldInputSchema)
.min(1)
.max(50)
.describe('Array of view fields to create (1-50 items).'),
});
const UpdateManyViewFieldsInputSchema = z.object({
viewFields: z
.array(UpdateViewFieldInputSchema)
.min(1)
.max(50)
.describe('Array of view field updates to apply (1-50 items).'),
});
@Injectable()
export class ViewFieldToolsFactory {
constructor(
private readonly viewFieldService: ViewFieldService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
private async resolveFieldName(
workspaceId: string,
fieldMetadataId: string,
): Promise<string | undefined> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const universalIdentifier =
flatFieldMetadataMaps.universalIdentifierById[fieldMetadataId];
if (!universalIdentifier) {
return undefined;
}
return flatFieldMetadataMaps.byUniversalIdentifier[universalIdentifier]
?.name;
}
generateReadTools(workspaceId: string): ToolSet {
return {
get_view_fields: {
description:
'List the columns (fields) displayed in a specific view. A view field controls which columns appear in a table or kanban view, their visibility, width, position, and aggregate operation. Use get_views first to find the view ID, then call this to inspect its column configuration.',
inputSchema: GetViewFieldsInputSchema,
execute: async (parameters: { viewId: string }) => {
const viewFields = await this.viewFieldService.findByViewId(
workspaceId,
parameters.viewId,
);
const viewFieldsWithNames = await Promise.all(
viewFields.map(async (viewField) => {
const fieldName = await this.resolveFieldName(
workspaceId,
viewField.fieldMetadataId,
);
return {
id: viewField.id,
fieldMetadataId: viewField.fieldMetadataId,
fieldName: fieldName ?? null,
viewId: viewField.viewId,
isVisible: viewField.isVisible,
size: viewField.size,
position: viewField.position,
aggregateOperation: viewField.aggregateOperation,
};
}),
);
return viewFieldsWithNames;
},
},
};
}
generateWriteTools(workspaceId: string): ToolSet {
return {
create_view_field: {
description:
'Add a new column to a view. View fields define which columns are shown in table or kanban views. First call get_field_metadata to find the fieldMetadataId of the column to add, and get_views to find the target viewId.',
inputSchema: CreateViewFieldInputSchema,
execute: async (parameters: {
viewId: string;
fieldMetadataId: string;
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: string;
}) => {
try {
const viewField = await this.viewFieldService.createOne({
createViewFieldInput: {
viewId: parameters.viewId,
fieldMetadataId: parameters.fieldMetadataId,
isVisible: parameters.isVisible ?? true,
size: parameters.size ?? 150,
position: parameters.position ?? 0,
aggregateOperation:
parameters.aggregateOperation as AggregateOperations,
},
workspaceId,
});
return {
id: viewField.id,
fieldMetadataId: viewField.fieldMetadataId,
viewId: viewField.viewId,
isVisible: viewField.isVisible,
size: viewField.size,
position: viewField.position,
aggregateOperation: viewField.aggregateOperation,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_view_field: {
description:
"Update properties of a column in a view. You can change its visibility, width (size in pixels), display position, or aggregate operation. Use get_view_fields to find the view field ID. Constraints: position must not be -1, must not precede the label identifier field, and must not conflict with another field's position.",
inputSchema: UpdateViewFieldInputSchema,
execute: async (parameters: {
id: string;
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: string;
}) => {
try {
const viewField = await this.viewFieldService.updateOne({
updateViewFieldInput: {
id: parameters.id,
update: {
isVisible: parameters.isVisible,
size: parameters.size,
position: parameters.position,
aggregateOperation:
parameters.aggregateOperation as AggregateOperations,
},
},
workspaceId,
});
return {
id: viewField.id,
fieldMetadataId: viewField.fieldMetadataId,
viewId: viewField.viewId,
isVisible: viewField.isVisible,
size: viewField.size,
position: viewField.position,
aggregateOperation: viewField.aggregateOperation,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
delete_view_field: {
description:
"Remove a column from a view. This removes the field from the view's displayed columns. Use get_view_fields to find the view field ID to delete.",
inputSchema: DeleteViewFieldInputSchema,
execute: async (parameters: { id: string }) => {
try {
const viewField = await this.viewFieldService.deleteOne({
deleteViewFieldInput: { id: parameters.id },
workspaceId,
});
return {
id: viewField.id,
deleted: true,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
create_many_view_fields: {
description:
'Add multiple columns to a view at once. More efficient than calling create_view_field multiple times. Each item follows the same schema as create_view_field. All view fields can target the same or different views.',
inputSchema: CreateManyViewFieldsInputSchema,
execute: async (parameters: {
viewFields: Array<{
viewId: string;
fieldMetadataId: string;
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: string;
}>;
}) => {
try {
await this.viewFieldService.createMany({
createViewFieldInputs: parameters.viewFields.map((viewField) => ({
viewId: viewField.viewId,
fieldMetadataId: viewField.fieldMetadataId,
isVisible: viewField.isVisible ?? true,
size: viewField.size ?? 150,
position: viewField.position ?? 0,
aggregateOperation:
viewField.aggregateOperation as AggregateOperations,
})),
workspaceId,
});
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
update_many_view_fields: {
description:
'Update multiple columns in a view at once. More efficient than calling update_view_field multiple times. Each item must include the view field ID and properties to update. Same constraints as update_view_field apply to each item.',
inputSchema: UpdateManyViewFieldsInputSchema,
execute: async (parameters: {
viewFields: Array<{
id: string;
isVisible?: boolean;
size?: number;
position?: number;
aggregateOperation?: string;
}>;
}) => {
try {
await Promise.all(
parameters.viewFields.map(async (viewField) => {
await this.viewFieldService.updateOne({
updateViewFieldInput: {
id: viewField.id,
update: {
isVisible: viewField.isVisible,
size: viewField.size,
position: viewField.position,
aggregateOperation:
viewField.aggregateOperation as AggregateOperations,
},
},
workspaceId,
});
}),
);
return true;
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -8,6 +8,7 @@ import { ViewFieldController } from 'src/engine/metadata-modules/view-field/cont
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldResolver } from 'src/engine/metadata-modules/view-field/resolvers/view-field.resolver';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -24,7 +25,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ViewPermissionsModule,
],
controllers: [ViewFieldController],
providers: [ViewFieldResolver, ViewFieldService],
exports: [ViewFieldService],
providers: [ViewFieldResolver, ViewFieldService, ViewFieldToolsFactory],
exports: [ViewFieldService, ViewFieldToolsFactory],
})
export class ViewFieldModule {}