Compare commits

...
Author SHA1 Message Date
Félix Malfait afa4cec67a Add calendar/kanban validation, resolveCalendarFieldMetadataId, and test fixtures with type property 2026-04-02 15:57:03 +02:00
Félix Malfait e01beca3fd Address PR review: ViewFilterValue type, calendar/kanban validation, system prompt cleanup, test fixtures 2026-04-02 15:51:44 +02:00
Félix Malfait 401ce9203a Remove NavigationMenuItemObjectDeactivationListener (prefer display-time filtering) 2026-04-02 15:34:51 +02:00
Félix Malfait d02b5b4da4 Remove ViewSortToolProvider (consolidated into ViewToolProvider) 2026-04-02 15:34:45 +02:00
Félix Malfait ac0e3c226d Remove ViewFilterToolProvider (consolidated into ViewToolProvider) 2026-04-02 15:34:39 +02:00
Félix Malfait c6588694a9 Address PR review comments: consolidate view tool providers, add validations
- Consolidate ViewFilterToolProvider and ViewSortToolProvider into ViewToolProvider
- Remove VIEW_FILTER and VIEW_SORT tool categories
- Remove NavigationMenuItemObjectDeactivationListener (prefer display-time filtering)
- Validate calendarFieldName is DATE/DATE_TIME field type
- Enforce mainGroupByFieldName for KANBAN views
- Enforce calendarFieldName + calendarLayout for CALENDAR views
- Fix "navigate tool" -> "navigate_app tool" in skill metadata
- Fix ViewFilterValue type assertions (was incorrectly cast as string)
- Add type property to mock field metadata fixtures in tests
- Add validation tests for KANBAN and CALENDAR view requirements
2026-04-02 15:32:06 +02:00
Lucas Bordeau 35bbb917bc Ok 2026-03-27 18:38:16 +01:00
18 changed files with 1470 additions and 107 deletions
@@ -65,10 +65,33 @@ export const useAgentChat = (
agentChatDraftsByThreadIdState,
);
const retryFetchWithRenewedToken = async (
const getCurrentAccessToken = () => {
const currentTokenPair = store.get(tokenPairState.atom);
return currentTokenPair?.accessOrWorkspaceAgnosticToken?.token;
};
const retryFetchWithCurrentOrRenewedToken = async (
input: RequestInfo | URL,
init?: RequestInit,
failedAccessToken?: string,
) => {
const currentAccessToken = getCurrentAccessToken();
if (
isDefined(currentAccessToken) &&
currentAccessToken !== failedAccessToken
) {
const updatedHeaders = new Headers(init?.headers ?? {});
updatedHeaders.set('Authorization', `Bearer ${currentAccessToken}`);
return fetch(input, {
...init,
headers: updatedHeaders,
});
}
const tokenPair = getTokenPair();
if (!isDefined(tokenPair)) {
@@ -82,7 +105,6 @@ export const useAgentChat = (
);
if (!isDefined(renewedTokens)) {
setTokenPair(null);
return null;
}
@@ -90,7 +112,6 @@ export const useAgentChat = (
renewedTokens.accessOrWorkspaceAgnosticToken?.token;
if (!isDefined(renewedAccessToken)) {
setTokenPair(null);
return null;
}
@@ -98,6 +119,7 @@ export const useAgentChat = (
setTokenPair(renewedTokens);
const updatedHeaders = new Headers(init?.headers ?? {});
updatedHeaders.set('Authorization', `Bearer ${renewedAccessToken}`);
return fetch(input, {
@@ -105,7 +127,6 @@ export const useAgentChat = (
headers: updatedHeaders,
});
} catch {
setTokenPair(null);
return null;
}
};
@@ -113,14 +134,26 @@ export const useAgentChat = (
const { sendMessage, messages, status, error, regenerate, stop } = useChat({
transport: new DefaultChatTransport({
api: `${REST_API_BASE_URL}/agent-chat/stream`,
headers: () => ({
Authorization: `Bearer ${getTokenPair()?.accessOrWorkspaceAgnosticToken.token}`,
}),
headers: () => {
const token = getCurrentAccessToken();
return {
Authorization: token ? `Bearer ${token}` : '',
};
},
fetch: async (input, init) => {
const response = await fetch(input, init);
if (response.status === 401) {
const retriedResponse = await retryFetchWithRenewedToken(input, init);
const failedAccessToken = new Headers(init?.headers ?? {})
.get('Authorization')
?.replace('Bearer ', '');
const retriedResponse = await retryFetchWithCurrentOrRenewedToken(
input,
init,
failedAccessToken,
);
return retriedResponse ?? response;
}
@@ -16,6 +16,8 @@ import {
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory';
import { ViewSortToolsFactory } from 'src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@Injectable()
@@ -24,23 +26,31 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
constructor(
private readonly viewToolsFactory: ViewToolsFactory,
private readonly viewFilterToolsFactory: ViewFilterToolsFactory,
private readonly viewSortToolsFactory: ViewSortToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const factory = this.viewToolsFactory;
const viewFactory = this.viewToolsFactory;
const filterFactory = this.viewFilterToolsFactory;
const sortFactory = this.viewSortToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.VIEW,
async (context) => {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = factory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
);
const readTools = {
...viewFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
),
...filterFactory.generateReadTools(context.workspaceId),
...sortFactory.generateReadTools(context.workspaceId),
};
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
@@ -50,10 +60,14 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
);
if (hasViewPermission) {
const writeTools = factory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
);
const writeTools = {
...viewFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
),
...filterFactory.generateWriteTools(context.workspaceId),
...sortFactory.generateWriteTools(context.workspaceId),
};
return { ...readTools, ...writeTools };
}
@@ -76,11 +90,15 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
includeSchemas: options?.includeSchemas ?? true,
};
const readTools = this.viewToolsFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
);
const readTools = {
...this.viewToolsFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
),
...this.viewFilterToolsFactory.generateReadTools(context.workspaceId),
...this.viewSortToolsFactory.generateReadTools(context.workspaceId),
};
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
@@ -90,10 +108,14 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
);
if (hasViewPermission) {
const writeTools = this.viewToolsFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
);
const writeTools = {
...this.viewToolsFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
),
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
...this.viewSortToolsFactory.generateWriteTools(context.workspaceId),
};
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
@@ -25,6 +25,8 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -48,6 +50,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
PermissionsModule,
ViewModule,
ViewFieldModule,
ViewFilterModule,
ViewSortModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
LogicFunctionModule,
@@ -19,6 +19,7 @@ import {
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@@ -182,66 +183,80 @@ export class NavigateAppTool implements Tool {
},
);
const availableObjectNames = navigationMenuItems
.map((navigationMenuItem) => {
if (isDefined(navigationMenuItem.viewId)) {
const correspondingViewUniversalIdentifier =
flatViewMaps.universalIdentifierById[navigationMenuItem.viewId];
type NavigatableObject = {
nameSingular: string;
labelSingular: string;
labelPlural: string;
};
if (isDefined(correspondingViewUniversalIdentifier)) {
const correspondingView =
flatViewMaps.byUniversalIdentifier[
correspondingViewUniversalIdentifier
];
const navigatableObjects = navigationMenuItems
.map<NavigatableObject | null>((navigationMenuItem) => {
const objectMetadataId =
navigationMenuItem.type === NavigationMenuItemType.OBJECT
? navigationMenuItem.targetObjectMetadataId
: navigationMenuItem.type === NavigationMenuItemType.VIEW &&
isDefined(navigationMenuItem.viewId)
? flatViewMaps.byUniversalIdentifier[
flatViewMaps.universalIdentifierById[
navigationMenuItem.viewId
] ?? ''
]?.objectMetadataId
: undefined;
if (isDefined(correspondingView)) {
const correspondingObjectMetadataUniversalIdentifier =
flatObjectMetadataMaps.universalIdentifierById[
correspondingView.objectMetadataId
];
if (isDefined(correspondingObjectMetadataUniversalIdentifier)) {
const correspondingObjectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
correspondingObjectMetadataUniversalIdentifier
];
if (isDefined(correspondingObjectMetadata)) {
const correspondingObjectNameSingular =
correspondingObjectMetadata.nameSingular;
return correspondingObjectNameSingular;
}
}
}
}
if (!isDefined(objectMetadataId)) {
return null;
}
return null;
const objectMetadataUniversalIdentifier =
flatObjectMetadataMaps.universalIdentifierById[objectMetadataId];
if (!isDefined(objectMetadataUniversalIdentifier)) {
return null;
}
const objectMetadata =
flatObjectMetadataMaps.byUniversalIdentifier[
objectMetadataUniversalIdentifier
];
if (!isDefined(objectMetadata)) {
return null;
}
return {
nameSingular: objectMetadata.nameSingular,
labelSingular: objectMetadata.labelSingular,
labelPlural: objectMetadata.labelPlural,
};
})
.filter(isDefined);
const fuse = new Fuse(availableObjectNames, {
threshold: 0.6,
const fuse = new Fuse(navigatableObjects, {
keys: ['nameSingular', 'labelSingular', 'labelPlural'],
threshold: 0.4,
});
const results = fuse.search(objectNameSingular.replace(/\s/g, ''));
const firstMatchingNavigationItemLabel = results[0]?.item;
const results = fuse.search(objectNameSingular);
const matchingObject = results[0]?.item;
if (!isDefined(matchingObject)) {
const availableLabels = navigatableObjects
.map((object) => object.labelPlural)
.join(', ');
if (!isDefined(firstMatchingNavigationItemLabel)) {
return {
success: false,
message: `Object "${objectNameSingular}" not found`,
error: `No object with singular name "${objectNameSingular}" was found in this workspace. Available objects: ${availableObjectNames}`,
error: `No object matching "${objectNameSingular}" was found in this workspace. Available objects: ${availableLabels}`,
};
}
return {
success: true,
message: `Navigating to ${firstMatchingNavigationItemLabel} default view`,
message: `Navigating to ${matchingObject.labelPlural} default view`,
result: {
action: 'navigateToObject',
objectNameSingular: firstMatchingNavigationItemLabel,
objectNameSingular: matchingObject.nameSingular,
},
};
}
@@ -326,7 +326,7 @@ ${hasWebSearch ? '3' : '2'}. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NA
case ToolCategory.METADATA:
return 'Metadata Tools (schema management)';
case ToolCategory.VIEW:
return 'View Tools (query views)';
return 'View Tools (manage views, filters, and sorts)';
case ToolCategory.DASHBOARD:
return 'Dashboard Tools (create/manage dashboards)';
case ToolCategory.LOGIC_FUNCTION:
@@ -188,6 +188,13 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
]);
}
if (isDefined(updateObjectInput.update.isActive)) {
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
});
}
return updatedFlatObjectMetadata;
}
@@ -0,0 +1,271 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { ViewFilterOperand } from 'twenty-shared/types';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { type ViewFilterValue } from 'src/engine/metadata-modules/view-filter/types/view-filter-value.type';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const VIEW_FILTER_OPERAND_OPTIONS = Object.values(ViewFilterOperand);
const GetViewFiltersInputSchema = z.object({
viewId: z
.string()
.uuid()
.describe(
'ID of the view to list filters for. Obtain this from get_views.',
),
});
const CreateViewFilterInputSchema = z.object({
viewId: z.string().uuid().describe('ID of the view to add the filter to'),
fieldMetadataId: z
.string()
.uuid()
.describe(
'ID of the field to filter on. Use list_object_metadata_items to find field IDs.',
),
operand: z
.enum(VIEW_FILTER_OPERAND_OPTIONS)
.describe(
'Filter operator. Valid operators per field type — TEXT/EMAILS/FULL_NAME: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. NUMBER/NUMERIC: IS, IS_NOT, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY. CURRENCY: GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY. DATE/DATE_TIME: IS, IS_RELATIVE, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY, IS_BEFORE, IS_AFTER, IS_EMPTY, IS_NOT_EMPTY. SELECT: IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. MULTI_SELECT/ARRAY: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY. RELATION: IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY. BOOLEAN: IS.',
),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string(), z.unknown()),
])
.describe(
'Filter value. Format depends on operand and field type: string for TEXT/SELECT, number for NUMBER/CURRENCY, array of option values for MULTI_SELECT IS/IS_NOT, empty string "" for IS_EMPTY/IS_NOT_EMPTY operators.',
),
subFieldName: z
.string()
.optional()
.describe(
'Required for composite fields — e.g. "amountMicros" for CURRENCY, "addressCity" for ADDRESS, "firstName" or "lastName" for FULL_NAME.',
),
});
const CreateManyViewFiltersInputSchema = z.object({
filters: z
.array(CreateViewFilterInputSchema)
.min(1)
.max(20)
.describe('Array of filters to create (1-20 items)'),
});
const UpdateViewFilterInputSchema = z.object({
id: z.string().uuid().describe('ID of the view filter to update'),
operand: z
.enum(VIEW_FILTER_OPERAND_OPTIONS)
.optional()
.describe('New filter operator'),
value: z
.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.string()),
z.record(z.string(), z.unknown()),
])
.optional()
.describe('New filter value'),
subFieldName: z
.string()
.optional()
.describe('New sub-field name for composite fields'),
});
const DeleteViewFilterInputSchema = z.object({
id: z.string().uuid().describe('ID of the view filter to delete'),
});
@Injectable()
export class ViewFilterToolsFactory {
constructor(private readonly viewFilterService: ViewFilterService) {}
generateReadTools(workspaceId: string): ToolSet {
return {
get_view_filters: {
description:
'List all filters applied to a view. Each filter defines a condition that records must match to appear in the view.',
inputSchema: GetViewFiltersInputSchema,
execute: async (parameters: { viewId: string }) => {
const filters = await this.viewFilterService.findByViewId(
workspaceId,
parameters.viewId,
);
return filters.map((filter) => ({
id: filter.id,
viewId: filter.viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
subFieldName: filter.subFieldName,
positionInViewFilterGroup: filter.positionInViewFilterGroup,
}));
},
},
};
}
generateWriteTools(workspaceId: string): ToolSet {
return {
create_view_filter: {
description:
'Add a filter to a view. Use list_object_metadata_items to get fieldMetadataId values.',
inputSchema: CreateViewFilterInputSchema,
execute: async (parameters: {
viewId: string;
fieldMetadataId: string;
operand: ViewFilterOperand;
value: unknown;
subFieldName?: string;
}) => {
try {
const filter = await this.viewFilterService.createOne({
createViewFilterInput: {
viewId: parameters.viewId,
fieldMetadataId: parameters.fieldMetadataId,
operand: parameters.operand,
value: parameters.value as ViewFilterValue,
subFieldName: parameters.subFieldName,
},
workspaceId,
});
return {
id: filter.id,
viewId: filter.viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
create_many_view_filters: {
description:
'Add multiple filters to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
inputSchema: CreateManyViewFiltersInputSchema,
execute: async (parameters: {
filters: Array<{
viewId: string;
fieldMetadataId: string;
operand: ViewFilterOperand;
value: unknown;
subFieldName?: string;
}>;
}) => {
const results = [];
for (const filterInput of parameters.filters) {
try {
const filter = await this.viewFilterService.createOne({
createViewFilterInput: {
viewId: filterInput.viewId,
fieldMetadataId: filterInput.fieldMetadataId,
operand: filterInput.operand,
value: filterInput.value as ViewFilterValue,
subFieldName: filterInput.subFieldName,
},
workspaceId,
});
results.push({
id: filter.id,
viewId: filter.viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
});
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
}
return { created: results };
},
},
update_view_filter: {
description:
'Update a filter on a view. Use get_view_filters to find the filter ID.',
inputSchema: UpdateViewFilterInputSchema,
execute: async (parameters: {
id: string;
operand?: ViewFilterOperand;
value?: unknown;
subFieldName?: string;
}) => {
try {
const filter = await this.viewFilterService.updateOne({
updateViewFilterInput: {
id: parameters.id,
update: {
operand: parameters.operand,
value: parameters.value as ViewFilterValue,
subFieldName: parameters.subFieldName,
},
},
workspaceId,
});
return {
id: filter.id,
viewId: filter.viewId,
fieldMetadataId: filter.fieldMetadataId,
operand: filter.operand,
value: filter.value,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
delete_view_filter: {
description:
'Remove a filter from a view. Use get_view_filters to find the filter ID.',
inputSchema: DeleteViewFilterInputSchema,
execute: async (parameters: { id: string }) => {
try {
const filter = await this.viewFilterService.deleteOne({
deleteViewFilterInput: { id: parameters.id },
workspaceId,
});
return {
id: filter.id,
deleted: true,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -8,6 +8,7 @@ import { ViewFilterController } from 'src/engine/metadata-modules/view-filter/co
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
import { ViewFilterResolver } from 'src/engine/metadata-modules/view-filter/resolvers/view-filter.resolver';
import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service';
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-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: [ViewFilterController],
providers: [ViewFilterService, ViewFilterResolver],
exports: [ViewFilterService],
providers: [ViewFilterService, ViewFilterResolver, ViewFilterToolsFactory],
exports: [ViewFilterService, ViewFilterToolsFactory],
})
export class ViewFilterModule {}
@@ -0,0 +1,219 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { ViewSortDirection } from 'src/engine/metadata-modules/view-sort/enums/view-sort-direction';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const VIEW_SORT_DIRECTION_OPTIONS = Object.values(ViewSortDirection);
const GetViewSortsInputSchema = z.object({
viewId: z
.string()
.uuid()
.describe('ID of the view to list sorts for. Obtain this from get_views.'),
});
const CreateViewSortInputSchema = z.object({
viewId: z.string().uuid().describe('ID of the view to add the sort to'),
fieldMetadataId: z
.string()
.uuid()
.describe(
'ID of the field to sort by. Use list_object_metadata_items to find field IDs.',
),
direction: z
.enum(VIEW_SORT_DIRECTION_OPTIONS)
.default(ViewSortDirection.ASC)
.describe('Sort direction: ASC (ascending) or DESC (descending)'),
});
const CreateManyViewSortsInputSchema = z.object({
sorts: z
.array(CreateViewSortInputSchema)
.min(1)
.max(10)
.describe('Array of sorts to create (1-10 items)'),
});
const UpdateViewSortInputSchema = z.object({
id: z.string().uuid().describe('ID of the view sort to update'),
direction: z
.enum(VIEW_SORT_DIRECTION_OPTIONS)
.optional()
.describe('New sort direction: ASC or DESC'),
});
const DeleteViewSortInputSchema = z.object({
id: z.string().uuid().describe('ID of the view sort to delete'),
});
@Injectable()
export class ViewSortToolsFactory {
constructor(private readonly viewSortService: ViewSortService) {}
generateReadTools(workspaceId: string): ToolSet {
return {
get_view_sorts: {
description:
'List all sorts applied to a view. Each sort defines a field and direction that determines the order records appear in the view.',
inputSchema: GetViewSortsInputSchema,
execute: async (parameters: { viewId: string }) => {
const sorts = await this.viewSortService.findByViewId(
workspaceId,
parameters.viewId,
);
return sorts.map((sort) => ({
id: sort.id,
viewId: sort.viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
}));
},
},
};
}
generateWriteTools(workspaceId: string): ToolSet {
return {
create_view_sort: {
description:
'Add a sort to a view. Use list_object_metadata_items to get fieldMetadataId values.',
inputSchema: CreateViewSortInputSchema,
execute: async (parameters: {
viewId: string;
fieldMetadataId: string;
direction: ViewSortDirection;
}) => {
try {
const sort = await this.viewSortService.createOne({
createViewSortInput: {
viewId: parameters.viewId,
fieldMetadataId: parameters.fieldMetadataId,
direction: parameters.direction,
},
workspaceId,
});
return {
id: sort.id,
viewId: sort.viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
create_many_view_sorts: {
description:
'Add multiple sorts to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
inputSchema: CreateManyViewSortsInputSchema,
execute: async (parameters: {
sorts: Array<{
viewId: string;
fieldMetadataId: string;
direction: ViewSortDirection;
}>;
}) => {
const results = [];
for (const sortInput of parameters.sorts) {
try {
const sort = await this.viewSortService.createOne({
createViewSortInput: {
viewId: sortInput.viewId,
fieldMetadataId: sortInput.fieldMetadataId,
direction: sortInput.direction,
},
workspaceId,
});
results.push({
id: sort.id,
viewId: sort.viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
});
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
}
return { created: results };
},
},
update_view_sort: {
description:
'Update a sort on a view. Use get_view_sorts to find the sort ID.',
inputSchema: UpdateViewSortInputSchema,
execute: async (parameters: {
id: string;
direction?: ViewSortDirection;
}) => {
try {
const sort = await this.viewSortService.updateOne({
updateViewSortInput: {
id: parameters.id,
update: {
direction: parameters.direction,
},
},
workspaceId,
});
return {
id: sort.id,
viewId: sort.viewId,
fieldMetadataId: sort.fieldMetadataId,
direction: sort.direction,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
delete_view_sort: {
description:
'Remove a sort from a view. Use get_view_sorts to find the sort ID.',
inputSchema: DeleteViewSortInputSchema,
execute: async (parameters: { id: string }) => {
try {
const sort = await this.viewSortService.deleteOne({
deleteViewSortInput: { id: parameters.id },
workspaceId,
});
return {
id: sort.id,
deleted: true,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
}
throw error;
}
},
},
};
}
}
@@ -8,6 +8,7 @@ import { ViewSortController } from 'src/engine/metadata-modules/view-sort/contro
import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/view-sort.entity';
import { ViewSortResolver } from 'src/engine/metadata-modules/view-sort/resolvers/view-sort.resolver';
import { ViewSortService } from 'src/engine/metadata-modules/view-sort/services/view-sort.service';
import { ViewSortToolsFactory } from 'src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -24,7 +25,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
ViewPermissionsModule,
],
controllers: [ViewSortController],
providers: [ViewSortService, ViewSortResolver],
exports: [ViewSortService],
providers: [ViewSortService, ViewSortResolver, ViewSortToolsFactory],
exports: [ViewSortService, ViewSortToolsFactory],
})
export class ViewSortModule {}
@@ -1,12 +1,16 @@
import { Test, type TestingModule } from '@nestjs/testing';
import {
FieldMetadataType,
OrderByDirection,
ViewType,
ViewVisibility,
} from 'twenty-shared/types';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
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 { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@@ -14,6 +18,7 @@ import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-to
describe('ViewToolsFactory', () => {
let viewToolsFactory: ViewToolsFactory;
let viewService: jest.Mocked<ViewService>;
let viewFieldService: jest.Mocked<ViewFieldService>;
let viewQueryParamsService: jest.Mocked<ViewQueryParamsService>;
let _flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
@@ -22,6 +27,36 @@ describe('ViewToolsFactory', () => {
const mockViewId = 'view-id';
const mockObjectMetadataId = 'object-metadata-id';
const mockObjectNameSingular = 'company';
const mockCalendarFieldMetadataId = 'calendar-field-metadata-id';
const mockNameFieldMetadataId = 'name-field-metadata-id';
const mockStageFieldMetadataId = 'stage-field-metadata-id';
const mockFlatFieldMetadataMaps = {
byUniversalIdentifier: {
'field-universal-id': {
id: mockCalendarFieldMetadataId,
name: 'dueAt',
type: FieldMetadataType.DATE_TIME,
objectMetadataId: mockObjectMetadataId,
universalIdentifier: 'field-universal-id',
},
'name-field-universal-id': {
id: mockNameFieldMetadataId,
name: 'name',
type: FieldMetadataType.TEXT,
objectMetadataId: mockObjectMetadataId,
universalIdentifier: 'name-field-universal-id',
},
'stage-field-universal-id': {
id: mockStageFieldMetadataId,
name: 'stage',
type: FieldMetadataType.SELECT,
objectMetadataId: mockObjectMetadataId,
universalIdentifier: 'stage-field-universal-id',
},
},
};
const mockView = {
id: mockViewId,
@@ -70,6 +105,12 @@ describe('ViewToolsFactory', () => {
deleteOne: jest.fn(),
},
},
{
provide: ViewFieldService,
useValue: {
createMany: jest.fn().mockResolvedValue([]),
},
},
{
provide: ViewQueryParamsService,
useValue: {
@@ -81,6 +122,7 @@ describe('ViewToolsFactory', () => {
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: mockFlatFieldMetadataMaps,
}),
},
},
@@ -89,6 +131,7 @@ describe('ViewToolsFactory', () => {
viewToolsFactory = module.get<ViewToolsFactory>(ViewToolsFactory);
viewService = module.get(ViewService);
viewFieldService = module.get(ViewFieldService);
viewQueryParamsService = module.get(ViewQueryParamsService);
_flatEntityMapsCacheService = module.get(
WorkspaceManyOrAllFlatEntityMapsCacheService,
@@ -275,6 +318,175 @@ describe('ViewToolsFactory', () => {
visibility: ViewVisibility.WORKSPACE,
});
});
it('should create view fields when fieldNames is provided', async () => {
const createdView = {
id: 'new-view-id',
name: 'Kanban View',
objectMetadataId: mockObjectMetadataId,
type: ViewType.KANBAN,
icon: 'IconLayoutKanban',
visibility: ViewVisibility.WORKSPACE,
};
viewService.createOne.mockResolvedValue(createdView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await callExecute(tools['create_view'], {
name: 'Kanban View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconLayoutKanban',
type: ViewType.KANBAN,
mainGroupByFieldName: 'stage',
fieldNames: ['name', 'stage'],
});
expect(viewFieldService.createMany).toHaveBeenCalledWith({
createViewFieldInputs: [
{
viewId: 'new-view-id',
fieldMetadataId: mockNameFieldMetadataId,
isVisible: true,
size: 150,
position: 0,
},
{
viewId: 'new-view-id',
fieldMetadataId: mockStageFieldMetadataId,
isVisible: true,
size: 150,
position: 1,
},
],
workspaceId: mockWorkspaceId,
});
});
it('should throw when KANBAN view missing mainGroupByFieldName', async () => {
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['create_view'], {
name: 'Kanban View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.KANBAN,
}),
).rejects.toThrow('KANBAN views require mainGroupByFieldName');
});
it('should throw when CALENDAR view missing calendarFieldName', async () => {
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['create_view'], {
name: 'Calendar View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.CALENDAR,
calendarLayout: ViewCalendarLayout.WEEK,
}),
).rejects.toThrow('CALENDAR views require calendarFieldName');
});
it('should throw when CALENDAR view missing calendarLayout', async () => {
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await expect(
callExecute(tools['create_view'], {
name: 'Calendar View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.CALENDAR,
calendarFieldName: 'dueAt',
}),
).rejects.toThrow('CALENDAR views require calendarLayout');
});
it('should not create view fields when fieldNames is not provided', async () => {
const createdView = {
id: 'new-view-id',
name: 'New View',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
icon: 'IconTable',
visibility: ViewVisibility.WORKSPACE,
};
viewService.createOne.mockResolvedValue(createdView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
await callExecute(tools['create_view'], {
name: 'New View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconTable',
});
expect(viewFieldService.createMany).not.toHaveBeenCalled();
});
it('should create a calendar view with layout and field', async () => {
const createdView = {
id: 'new-calendar-view-id',
name: 'Calendar View',
objectMetadataId: mockObjectMetadataId,
type: ViewType.CALENDAR,
icon: 'IconCalendar',
visibility: ViewVisibility.WORKSPACE,
};
viewService.createOne.mockResolvedValue(createdView as any);
const tools = viewToolsFactory.generateWriteTools(
mockWorkspaceId,
mockUserWorkspaceId,
);
const result = await callExecute(tools['create_view'], {
name: 'Calendar View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconCalendar',
type: ViewType.CALENDAR,
calendarLayout: ViewCalendarLayout.WEEK,
calendarFieldName: 'dueAt',
});
expect(viewService.createOne).toHaveBeenCalledWith({
createViewInput: {
name: 'Calendar View',
objectMetadataId: mockObjectMetadataId,
icon: 'IconCalendar',
type: ViewType.CALENDAR,
visibility: ViewVisibility.WORKSPACE,
calendarLayout: ViewCalendarLayout.WEEK,
calendarFieldMetadataId: mockCalendarFieldMetadataId,
},
workspaceId: mockWorkspaceId,
createdByUserWorkspaceId: mockUserWorkspaceId,
});
expect(result).toEqual({
id: 'new-calendar-view-id',
name: 'Calendar View',
objectNameSingular: mockObjectNameSingular,
type: ViewType.CALENDAR,
icon: 'IconCalendar',
visibility: ViewVisibility.WORKSPACE,
});
});
});
describe('update-view tool', () => {
@@ -1,19 +1,24 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { z } from 'zod';
import {
AggregateOperations,
FieldMetadataType,
ViewType,
ViewVisibility,
} 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 { buildObjectIdByNameMaps } from 'src/engine/metadata-modules/flat-object-metadata/utils/build-object-id-by-name-maps.util';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum';
import { ViewQueryParamsService } from 'src/engine/metadata-modules/view/services/view-query-params.service';
import { ViewService } from 'src/engine/metadata-modules/view/services/view.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { isFieldMetadataDateKind, isNonEmptyArray } from 'twenty-shared/utils';
const GetViewsInputSchema = z.object({
objectNameSingular: z
@@ -76,6 +81,28 @@ const CreateViewInputSchema = z.object({
.string()
.optional()
.describe('Field name for the kanban aggregate operation (e.g., "amount")'),
calendarLayout: z
.enum([
ViewCalendarLayout.DAY,
ViewCalendarLayout.WEEK,
ViewCalendarLayout.MONTH,
])
.optional()
.describe(
'Calendar layout (required for CALENDAR views, e.g., "DAY", "WEEK", "MONTH")',
),
calendarFieldName: z
.string()
.optional()
.describe(
'Date field name to use for the calendar (required for CALENDAR views, must be a DATE or DATE_TIME field, e.g., "createdAt", "dueAt")',
),
fieldNames: z
.array(z.string())
.optional()
.describe(
'Field names to display in the view as columns (for TABLE) or cards (for KANBAN/CALENDAR). Fields are displayed in the order provided. Use get_field_metadata to find available field names.',
),
});
const UpdateViewInputSchema = z.object({
@@ -92,6 +119,7 @@ const DeleteViewInputSchema = z.object({
export class ViewToolsFactory {
constructor(
private readonly viewService: ViewService,
private readonly viewFieldService: ViewFieldService,
private readonly viewQueryParamsService: ViewQueryParamsService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
@@ -153,6 +181,78 @@ export class ViewToolsFactory {
return fieldMetadata.id;
}
private async resolveGroupByFieldMetadataId(
workspaceId: string,
objectMetadataId: string,
fieldName: string,
): Promise<string> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const fieldMetadata = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).find(
(field) =>
field?.name === fieldName &&
field?.objectMetadataId === objectMetadataId,
);
if (!fieldMetadata) {
throw new Error(
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
);
}
if (fieldMetadata.type !== FieldMetadataType.SELECT) {
throw new Error(
`Field "${fieldName}" has type "${fieldMetadata.type}" and cannot be used as a group-by field. Only SELECT fields are supported for grouping (board columns and table groups).`,
);
}
return fieldMetadata.id;
}
private async resolveCalendarFieldMetadataId(
workspaceId: string,
objectMetadataId: string,
fieldName: string,
): Promise<string> {
const { flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps'],
},
);
const fieldMetadata = Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).find(
(field) =>
field?.name === fieldName &&
field?.objectMetadataId === objectMetadataId,
);
if (!fieldMetadata) {
throw new Error(
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
);
}
if (!isFieldMetadataDateKind(fieldMetadata.type)) {
throw new Error(
`Field "${fieldName}" has type "${fieldMetadata.type}" and cannot be used as a calendar field. Only DATE or DATE_TIME fields are supported.`,
);
}
return fieldMetadata.id;
}
generateReadTools(
workspaceId: string,
userWorkspaceId?: string,
@@ -219,7 +319,7 @@ export class ViewToolsFactory {
return {
create_view: {
description:
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status").',
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status"). For CALENDAR views, calendarFieldName and calendarLayout are required.',
inputSchema: CreateViewInputSchema,
execute: async (parameters: {
name: string;
@@ -230,6 +330,9 @@ export class ViewToolsFactory {
mainGroupByFieldName?: string;
kanbanAggregateOperation?: string;
kanbanAggregateOperationFieldName?: string;
calendarLayout?: ViewCalendarLayout;
calendarFieldName?: string;
fieldNames?: string[];
}) => {
try {
const objectMetadataId = await this.resolveObjectMetadataId(
@@ -237,15 +340,40 @@ export class ViewToolsFactory {
parameters.objectNameSingular,
);
if (
parameters.type === ViewType.KANBAN &&
!parameters.mainGroupByFieldName
) {
throw new Error(
'KANBAN views require mainGroupByFieldName. Provide a SELECT field name (e.g., "stage", "status") to group records into columns.',
);
}
if (parameters.type === ViewType.CALENDAR) {
if (!parameters.calendarFieldName) {
throw new Error(
'CALENDAR views require calendarFieldName. Provide a DATE or DATE_TIME field name (e.g., "dueAt", "createdAt").',
);
}
if (!parameters.calendarLayout) {
throw new Error(
'CALENDAR views require calendarLayout. Provide one of: "DAY", "WEEK", "MONTH".',
);
}
}
let mainGroupByFieldMetadataId: string | undefined;
let kanbanAggregateOperationFieldMetadataId: string | undefined;
let calendarFieldMetadataId: string | undefined;
if (parameters.mainGroupByFieldName) {
mainGroupByFieldMetadataId = await this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.mainGroupByFieldName,
);
mainGroupByFieldMetadataId =
await this.resolveGroupByFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.mainGroupByFieldName,
);
}
if (parameters.kanbanAggregateOperationFieldName) {
@@ -257,6 +385,15 @@ export class ViewToolsFactory {
);
}
if (parameters.calendarFieldName) {
calendarFieldMetadataId =
await this.resolveCalendarFieldMetadataId(
workspaceId,
objectMetadataId,
parameters.calendarFieldName,
);
}
const view = await this.viewService.createOne({
createViewInput: {
name: parameters.name,
@@ -268,11 +405,38 @@ export class ViewToolsFactory {
kanbanAggregateOperation:
parameters.kanbanAggregateOperation as AggregateOperations,
kanbanAggregateOperationFieldMetadataId,
calendarLayout: parameters.calendarLayout,
calendarFieldMetadataId,
},
workspaceId,
createdByUserWorkspaceId: userWorkspaceId,
});
if (isNonEmptyArray(parameters.fieldNames)) {
const resolvedFieldMetadataIds = await Promise.all(
parameters.fieldNames.map((fieldName) =>
this.resolveFieldMetadataId(
workspaceId,
objectMetadataId,
fieldName,
),
),
);
await this.viewFieldService.createMany({
createViewFieldInputs: resolvedFieldMetadataIds.map(
(fieldMetadataId, index) => ({
viewId: view.id,
fieldMetadataId,
isVisible: true,
size: 150,
position: index,
}),
),
workspaceId,
});
}
return {
id: view.id,
name: view.name,
@@ -8,6 +8,8 @@ import { FlatViewModule } from 'src/engine/metadata-modules/flat-view/flat-view.
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewFieldGroupModule } from 'src/engine/metadata-modules/view-field-group/view-field-group.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller';
@@ -24,6 +26,8 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
TypeOrmModule.forFeature([ViewEntity]),
ViewPermissionsModule,
ViewFieldGroupModule,
ViewFieldModule,
ViewFilterModule,
ViewSortModule,
I18nModule,
ApplicationModule,
@@ -32,6 +32,15 @@ export const STANDARD_SKILL = {
'workspace-demo-seeding': {
universalIdentifier: '20202020-c81b-4af8-9255-4c34bd0eac9c',
},
'view-building': {
universalIdentifier: '20202020-e4a2-4b3f-9c71-d8f6a2b51e3a',
},
'view-filters-and-sorts': {
universalIdentifier: '20202020-f5b3-4c4e-8d82-e9a7b3c62f4b',
},
'custom-objects-cleanup': {
universalIdentifier: '20202020-a1d3-4e5f-b6c7-8d9e0f1a2b3c',
},
} as const satisfies Record<
string,
{
@@ -149,22 +149,27 @@ Prioritize data integrity and provide clear feedback on operations performed.`,
'Seeding demo metadata and data for workspace setup and testing purposes',
icon: 'IconDatabase',
content: `# Workspace Demo Seeding Skill
You will create a demo workspace that fits a particular type of company given by the user.
You will transform the existing standard workspace into a fully custom demo tailored to the user's business type.
Do not ask the user for more information, just be creative with the objects and fields, but stay professional and coherent.
The goal is to tell a coherent and realistic story with the data: custom fields added to standard objects, new custom objects for domain-specific entities, rich relations, seeded and updated records, views, and enrichment data (emails, calendar events, tasks, notes, files) that make the workspace feel like a real company in operation.
The goal is to be able to tell a coherent and realistic story with the data, so for example if the user says it is a car repair shop,
we can create objects for cars, employees, repairs, customers, and the relevant relations between them,
and then we can seed data that tells the story of the car repair shop, for example we can create a customer, then create a car for that customer, then create a repair for that car, and so on.
We should end up with a dashboard that shows the relevant metrics for the car repair shop, for example the number of repairs, the revenue, the most common car brands, and so on.
## Object strategy
Create relations fields between objects, for example a car repair shop workspace would have objects for cars, employees, repairs, customers, and the relevant relations between them.
**Keep the standard objects — People, Companies, and Opportunities — and reuse their existing seed data.** They already have emails and calendar events linked to them as participants. The demo story is built on top of them, not instead of them.
Create 5 to 7 objects, with 5 to 8 fields each, and the relevant relations between them.
- **People** → map to the domain's "contact" role (e.g. clients, candidates, customers, agents)
- **Companies** → map to the domain's "organisation" role (e.g. suppliers, agencies, employers)
- **Opportunities** → map to the domain's "deal/pipeline" role (e.g. job applications, deals, repair estimates)
**Add 2 to 3 additional custom objects** for domain-specific entities that don't map to People/Companies/Opportunities (e.g. Properties, Cars, Products, Projects). Keep the custom object count low — the standard objects carry most of the story.
**Add custom fields** to People, Companies, and Opportunities to enrich them with domain-specific data (e.g. add "specialisation", "licenseNumber" to People; add "industry vertical", "tier" to Companies; add "stage", "closeDate" to Opportunities).
Create rich relation fields between standard and custom objects to show off the relational capabilities of the platform.
If you have to create multiple things you *MUST* use the relevant create many tool if it exists:
- Use *create_many_object_metadata* to create all objects at once
- Use *create_many_field_metadata* to create all non-relation fields at once
- Use *create_many_object_metadata* to create all custom objects at once
- Use *create_many_field_metadata* to create all non-relation fields at once (including new fields on standard objects)
- Use *create_many_relation_fields* to create all relation fields between objects at once (do this AFTER creating the objects and non-relation fields)
If you have to wait use the navigate tool.
@@ -174,14 +179,30 @@ For the fields you will create, make sure to create a good variety of field type
- Create DATE_TIME fields to be able to create calendar views
- Create CURRENCY and NUMERIC fields for graphs
*Here are the steps to follow closely :*
*Here are the steps to follow closely:*
STEP 1: Create all the objects at once with create_many_object_metadata, DO THIS FIRST
STEP 0: Present a plan to the user and wait for approval.
- Use list_object_metadata_items to see all available objects in the workspace
- Use find_people (limit: 5) and find_companies (limit: 5) and find_opportunities (limit: 5) to understand the existing seed data shape
- Based on the user's business type, propose a plan that lists:
- How People, Companies, and Opportunities map to the domain story (e.g. "People = Candidates", "Companies = Employers")
- The 23 custom objects you will create, each with a one-line description of their role
- The custom fields you will add to People, Companies, and Opportunities
- A brief description of the key relations between objects
- Present this plan to the user and *wait for their confirmation or adjustments* before proceeding
- Once approved, call the custom-objects-cleanup skill to archive all existing custom objects without asking for user confirmation
- Wait 3 seconds after deletions for the backend side effects to be completed
STEP 1: Create all the custom objects at once with create_many_object_metadata
name must start with lowercase letter and contain only alphanumeric letters
STEP 2: Wait 3 seconds, for the backend side effects to be completed
STEP 3: Create all NON-RELATION fields for ALL objects by batch with create_many_field_metadata, do a batch for each object.
STEP 3: Create all NON-RELATION fields for ALL objects by batch with create_many_field_metadata.
Do a separate batch call for each object.
This includes:
- New custom fields for the standard objects (Person, Company, Opportunity) — use their objectMetadataId from list_object_metadata_items
- All non-relation fields for the new custom objects
DO NOT include relation fields in this step. Only create TEXT, NUMBER, BOOLEAN, DATE_TIME, SELECT, MULTI_SELECT, CURRENCY, etc.
SELECT option values must be UPPER_SNAKE_CASE
@@ -193,25 +214,100 @@ targetFieldIcon is like IconSomething, it's ok if it doesn't exist in the icon l
STEP 6: Wait 3 seconds, for the backend side effects to be completed
STEP 7: For each object:
- Navigate the object's default view USE THE NAVIGATE TOOL
STEP 7: Rename and enrich the first N records of People, Companies, and Opportunities.
- Use find_people (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_companies (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_opportunities (limit: 50, orderBy: [{ position: "AscNullsFirst" }]) to get the IDs of the first records in each table
- Ordering by position ascending gives the earliest-inserted records, which are contiguous in the table — this keeps the demo data tightly grouped and makes the workspace feel coherent
- For each standard object, call update_people / update_companies / update_opportunities **individually per record** (one call per record) to set domain-relevant names and field values:
- **People**: replace nameFirstName + nameLastName with realistic names that fit the domain role (e.g. for a law firm: "Sophie Martin", "James O'Brien"; for a clinic: "Dr. Clara Reyes", "Marco Bianchi"). Also set jobTitle to a domain-appropriate title.
- **Companies**: replace name with realistic company names that fit the domain (e.g. for a law firm: "Ashford & Partners", "Nexus Legal Group"; for a clinic: "Meridian Health Clinic", "CarePoint Medical").
- **Opportunities**: replace name with a domain-relevant deal name (e.g. "Q2 retainer — Ashford & Partners", "New patient intake — Meridian Health").
- Also set the new custom fields on each record: spread realistic values across SELECT fields, set plausible CURRENCY/NUMERIC amounts, set DATE_TIME fields around TODAY.
- Do this one record at a time — the API does not support bulk individual updates with different values per record
- Wait 3 seconds after finishing all updates for one object type before moving to the next
STEP 7.5: Add view fields to the default views of standard objects to expose the new custom fields.
For each of People, Companies, and Opportunities:
- Navigate to the object's default view using the navigate tool
- Wait 3 seconds
- Use create_many_view_fields to add all the new custom fields to the default view so they are visible
- Use decimal positions between 0 and 1 to insert them right after the label identifier field
- Navigate to the object's default view again using the navigate tool so the user can see the enriched records
- Wait 3 seconds
STEP 8: For each new custom object, repeat ALL of the following sub-steps before moving to the next object:
- Navigate the object's default view using the navigate tool
- Wait 3 seconds, so the user has time to see the object default view
- Create the view fields for the default view, use the create_many_view_fields tool, and make sure to include all created fields, including the relation fields, so that we have a complete view of the object with all its fields.
BE CAREFUL to use a position that will put those view fields right after the first label identifier field
which has a position of 0 and the next system created fields which begin at 1, *so use decimal positions between 0 and 1*
*YOU MUST CREATE ALL VIEW FIELDS FOR ALL FIELDS, INCLUDING RELATION FIELDS, IN THIS STEP, DO NOT LEAVE ANY FIELD WITHOUT A VIEW FIELD, OTHERWISE IT WILL NOT BE VISIBLE IN THE DEFAULT VIEW AND THE USER WON'T KNOW IT EXISTS*
BE CAREFUL to use a position that will put those view fields right after the first label identifier field
which has a position of 0 and the next system created fields which begin at 1, *so use decimal positions between 0 and 1*
*YOU MUST CREATE ALL VIEW FIELDS FOR ALL FIELDS, INCLUDING RELATION FIELDS, IN THIS STEP, DO NOT LEAVE ANY FIELD WITHOUT A VIEW FIELD, OTHERWISE IT WILL NOT BE VISIBLE IN THE DEFAULT VIEW AND THE USER WON'T KNOW IT EXISTS*
- Then seed relevant and realistic mock data as we said earlier :
- **MANDATORY**: Navigate to the object's default view again using the navigate tool — YOU MUST DO THIS BEFORE EACH OBJECT'S DATA SEEDING, every single time, without exception
- Wait 3 seconds
- Seed relevant and realistic mock data for this object:
- use the relevant tool to create many records for this object
- between 20 and 50
- with a coherent combination of values
- link records to existing People and Companies using the relation fields you created
- use dates that are around TODAY so it's relevant for seeing past / future and present records
Loop STEP 7 for all the objects
- **MANDATORY**: Navigate to the object's default view again using the navigate tool so the user can see the populated data — DO NOT SKIP THIS, even if you already navigated earlier in this loop iteration
- Wait 3 seconds so the user has time to see the seeded records
STEP 8 : After you've finished with this part, let's proceed to the dashboard creation. We will create a dashboard with 4 graphs.
- Create a new dashboard with a relevant story to showcase the data you've just created, for example if it's a car repair shop, you can create a dashboard that shows the number of repairs per month, the revenue per month, the most common car brands, and the distribution of repairs by employee.
- Navigate to the dashboard page
- Then create 2 to 3 additional views for this object, one at a time. For each view, complete ALL of the following sub-steps before creating the next view:
- Create the view using the create_view tool:
- If the object has a SELECT field (e.g. status, stage, priority, type), create a **KANBAN** view grouped by that SELECT field with a relevant name like "By Status", "Pipeline", "By Priority".
- Set kanbanAggregateOperation to COUNT so each column shows the number of records.
- If there is a CURRENCY or NUMERIC field, also set kanbanAggregateOperationFieldName to that field for a SUM aggregate view.
- If the object has a DATE_TIME field (e.g. dueDate, closedAt, scheduledAt), create a **CALENDAR** view using that field with a relevant name like "Calendar", "Schedule", "Timeline".
- Create a **TABLE** view with a meaningful group (mainGroupByFieldName set to a SELECT field) with a name like "By Type", "By Stage", "Grouped", or similar.
- Use create_many_view_fields to add all relevant field columns to this view (using decimal positions between 0 and 1)
- Add filters and sorts to this view:
- **KANBAN views**: Sort by a CURRENCY or NUMERIC field DESC (biggest value first) if one exists, or by createdAt DESC. Add a filter to exclude archived/cancelled records if such a SELECT option exists.
- **CALENDAR views**: Sort by the date field ASC (earliest events first). Add a filter using IS_IN_FUTURE or IS_RELATIVE to show only upcoming records by default.
- **TABLE with groups**: Sort by createdAt DESC (most recent first) and add a filter on a meaningful field (e.g. status IS_NOT "CANCELLED", or amount GREATER_THAN_OR_EQUAL to some threshold that keeps ~80% of the records visible).
- **MANDATORY**: Navigate to this view immediately using the navigate tool — YOU MUST DO THIS FOR EVERY SINGLE VIEW, right after its fields/filters/sorts are set up, without exception
- Wait 3 seconds so the user can see the view and course-correct if needed
Also create additional views for the standard objects (People, Companies, Opportunities) that showcase the new custom fields:
- For People: a KANBAN view grouped by the new SELECT field you added (e.g. "By Specialisation", "By Status")
- For Opportunities: a KANBAN view grouped by the new stage/status field (pipeline view)
- For Companies: a TABLE view grouped by the new SELECT field
Navigate to each view after creating it. Wait 3 seconds.
Loop STEP 8 for all the custom objects
STEP 9: Create a multi-tab dashboard that tells the full story of the business.
Use create_complete_dashboard to create the first tab, then add_dashboard_tab + add_dashboard_widget for subsequent tabs.
**Structure: 3 tabs**
Tab 1 — "Overview": high-level KPIs and charts across the whole workspace
- Row 0: 34 AGGREGATE_CHART widgets (KPIs) — one per key metric (e.g. total revenue from Opportunities, count of active People, count of open deals). columnSpan 34, rowSpan 3.
- Row 3: 12 BAR_CHART or LINE_CHART widgets showing trends over time (group by a DATE_TIME field with MONTH granularity). columnSpan 6, rowSpan 7.
- Row 3: 1 PIE_CHART showing distribution by a SELECT field (e.g. status, type). columnSpan 6, rowSpan 7.
- Row 10: 1 STANDALONE_RICH_TEXT widget summarising the dashboard story. columnSpan 12, rowSpan 3.
Tab 2 — "[Domain object] pipeline" (e.g. "Deals", "Applications", "Repairs"): focus on Opportunities enriched with domain data
- Before adding the RECORD_TABLE widget, run this 3-step sequence:
1. create_view (type TABLE, name e.g. "Active Deals") → get the new viewId
2. create_many_view_fields on the new viewId — add 46 key fields (name, the new stage/status SELECT, a CURRENCY/NUMERIC field, a DATE field, linked Person or Company). Use positions 0, 1, 2… and isVisible: true.
3. create_many_view_filters + create_view_sort — e.g. filter out CLOSED/LOST records (SELECT IS_NOT "CLOSED"), sort by value DESC
- Row 0: 1 RECORD_TABLE widget. Set objectMetadataId to Opportunity, configuration.viewId to the dedicated view. columnSpan 12, rowSpan 8.
- Row 8: 1 BAR_CHART grouped by the stage SELECT field. columnSpan 6, rowSpan 7.
- Row 8: 1 PIE_CHART or AGGREGATE_CHART on the CURRENCY field. columnSpan 6, rowSpan 7.
Tab 3 — "[Domain people role] list" (e.g. "Clients", "Candidates", "Contacts"): focus on People enriched with domain data
- Before adding the RECORD_TABLE widget, run this 3-step sequence:
1. create_view (type TABLE, name e.g. "All Clients") → get the new viewId
2. create_many_view_fields — add 45 key fields (name, email, the new SELECT/status field, a DATE field, linked Company)
3. create_view_sort — sort by createdAt DESC or by name ASC
- Row 0: 1 RECORD_TABLE widget with the dedicated view. columnSpan 12, rowSpan 8.
- Row 8: 23 AGGREGATE_CHART KPIs (count, totals). columnSpan 4, rowSpan 3.
- Row 11: 1 BAR_CHART or LINE_CHART. columnSpan 12, rowSpan 7.
After creating the dashboard, navigate to the dashboard page.
`,
isCustom: false,
},
@@ -295,6 +391,15 @@ You help users create and manage dashboards with widgets.
- STANDALONE_RICH_TEXT: configurationType "STANDALONE_RICH_TEXT" + body with markdown content
- IMPORTANT: Put the actual text content in configuration.body.markdown, NOT in the widget title
- Widget title should be a short label (e.g. "Notes", "Summary"), body.markdown holds the real content
- RECORD_TABLE: configurationType "RECORD_TABLE" — displays a filterable, sortable record list
- **MANDATORY 3-step pre-sequence before creating the widget**:
1. call create_view (type TABLE, name e.g. "Repairs Dashboard Table") → get the new viewId
2. call create_many_view_fields on the new viewId — add 46 of the most relevant fields (label identifier + key SELECT/DATE/CURRENCY fields). Use positions 0, 1, 2… and isVisible: true.
3. call create_many_view_filters and/or create_view_sort on the new viewId to focus the table (e.g. filter out DONE/CANCELLED records, sort by createdAt DESC or a date field ASC)
- Never reuse a record index view — widget views and record index views must be separate
- Set objectMetadataId on the widget (top-level, required)
- Set configuration.viewId to the UUID of the dedicated view (required)
- columnSpan 12 (full width) or 6 (half width), rowSpan 610
Example (STANDALONE_RICH_TEXT):
{
@@ -302,12 +407,26 @@ Example (STANDALONE_RICH_TEXT):
"body": { "markdown": "## Quarterly Summary\\n\\nKey metrics:\\n- Revenue up 15%\\n- 42 new deals closed\\n\\n**Next steps**: Focus on enterprise pipeline." }
}
Example (RECORD_TABLE — always run the 3-step pre-sequence first):
Step 1 — create_view: { "name": "Active Repairs", "objectNameSingular": "repair", "type": "TABLE" } → { "id": "<view-uuid>" }
Step 2 — create_many_view_fields: { "viewFields": [{ "viewId": "<view-uuid>", "fieldMetadataId": "<status-field-uuid>", "position": 1, "isVisible": true }, { "viewId": "<view-uuid>", "fieldMetadataId": "<amount-field-uuid>", "position": 2, "isVisible": true }] }
Step 3 — create_many_view_filters: { "filters": [{ "viewId": "<view-uuid>", "fieldMetadataId": "<status-field-uuid>", "operand": "IS_NOT", "value": "DONE" }] }
Step 3b — create_view_sort: { "viewId": "<view-uuid>", "fieldMetadataId": "<createdAt-field-uuid>", "direction": "DESC" }
Step 4 — add_dashboard_widget: { "type": "RECORD_TABLE", "objectMetadataId": "<repair-object-uuid>", "configuration": { "configurationType": "RECORD_TABLE", "viewId": "<view-uuid>" }, "gridPosition": { "row": 0, "column": 0, "rowSpan": 8, "columnSpan": 12 } }
## Tabs
Use add_dashboard_tab to create multiple tabs in a dashboard. Each tab has its own set of widgets.
Good tab structure: one overview tab (KPIs + charts) + one or more detail tabs (RECORD_TABLE + focused charts).
After creating a tab, use its returned tabId as pageLayoutTabId when calling add_dashboard_widget.
## Grid System
- 12 columns (0-11)
- KPI widgets: rowSpan 2-4, columnSpan 3-4
- Charts: rowSpan 6-8, columnSpan 6-12
- Common layouts: 4 KPIs in a row (columnSpan 3), 2 charts side by side (columnSpan 6), full width chart (columnSpan 12)
- Record tables: rowSpan 6-10, columnSpan 6-12 (full-width preferred)
- Common layouts: 4 KPIs in a row (columnSpan 3), 2 charts side by side (columnSpan 6), full width chart or table (columnSpan 12)
## Best Practices
@@ -315,7 +434,8 @@ Example (STANDALONE_RICH_TEXT):
- Group related charts together
- Use consistent heights within rows
- Start simple, add complexity as needed
- When modifying a chart, confirm whether the user wants to change settings or change chart type`,
- When modifying a chart, confirm whether the user wants to change settings or change chart type
- Use RECORD_TABLE widgets to give users direct access to filtered record lists without leaving the dashboard`,
isCustom: false,
},
}),
@@ -973,6 +1093,262 @@ para.paragraph_format.space_after = Pt(12)
},
}),
'view-building': (args: Omit<CreateStandardSkillArgs, 'context'>) =>
createStandardSkillFlatMetadata({
...args,
context: {
skillName: 'view-building',
name: 'view-building',
label: 'View Building',
description:
'Creating and configuring views (table, board/kanban, calendar) for objects to organize and visualize records',
icon: 'IconLayoutBoard',
content: `# View Building Skill
You help users create and configure views to organize how they see their records.
## View Types
- **TABLE**: Standard table/grid view. Works for any object. Default view type.
- **KANBAN**: Board view grouped by a SELECT field. Best for pipeline/status-based workflows.
- **CALENDAR**: Calendar view using a DATE or DATE_TIME field. Best for time-based records.
## Tools
- get_views - List existing views (filter by object name)
- create_view - Create a new view
- update_view - Update view name/icon
- delete_view - Delete a view
- create_many_view_fields - Add visible columns to a view
- update_many_view_fields - Update column configuration
- get_view_fields - List columns in a view
- list_object_metadata_items - Discover objects and their fields
- navigate_app - Navigate to a view after creation
## Workflow
1. **Identify the target object**: If the user didn't specify which object, ask them. Present available objects and explain what each holds:
- **Company**: Business accounts (name, domain, employees, revenue, address)
- **Person**: Contacts (name, email, phone, job title, company)
- **Opportunity**: Pipeline deals (name, stage, amount, close date, company, contact)
- **Task**: Action items (title, status, due date, assignee)
- **Note**: Free-form notes (title, body)
- Plus any custom objects in the workspace
2. **Choose the view type**: Suggest the best type based on the object's data:
- TABLE: Good default for any object, great for browsing large datasets
- KANBAN: Ideal when objects have a SELECT field representing stages/statuses (e.g., Opportunity → stage, Task → status)
- CALENDAR: Ideal when objects have DATE/DATE_TIME fields (e.g., Opportunity → closeDate, Task → dueAt)
3. **Create the view**: Use create_view with the right parameters.
- For KANBAN: The mainGroupByFieldName is required — ask user which SELECT field to group by, or suggest the most natural one.
- For CALENDAR: The system will use the appropriate date field.
- For TABLE: No special configuration needed.
4. **Configure view fields**: Use create_many_view_fields to add relevant columns. Choose fields that make sense for the view's purpose. Use decimal positions between 0 and 1 to place them after the label identifier field.
5. **Navigate**: Use navigate_app to show the user their new view.
## KANBAN Best Practices
- The grouping field must be a SELECT type
- Common groupings: Opportunity by stage, Task by status
- Optionally set kanbanAggregateOperation (COUNT, SUM, AVG, MIN, MAX) and kanbanAggregateOperationFieldName for column summaries
- Example: Sum of amount per stage for Opportunity board
## CALENDAR Best Practices
- Requires a DATE or DATE_TIME field on the object
- Best for: Opportunity close dates, Task due dates, any event-based data
## TABLE with Groups
- TABLE views can also be grouped by a field using mainGroupByFieldName
- This creates collapsible sections in the table, organized by the grouping field values
- Works with SELECT fields for categorical grouping
## Approach
- If the user is vague (e.g., "create a board"), ask which object they want to see
- Suggest the most relevant view type based on the object's fields
- After creating a view, always configure useful view fields and navigate to it
- Explain what each view type does so users can make informed choices`,
isCustom: false,
},
}),
'view-filters-and-sorts': (args: Omit<CreateStandardSkillArgs, 'context'>) =>
createStandardSkillFlatMetadata({
...args,
context: {
skillName: 'view-filters-and-sorts',
name: 'view-filters-and-sorts',
label: 'View Filters & Sorts',
description:
'Adding filters and sorts to views to focus on relevant records based on user needs',
icon: 'IconFilter',
content: `# View Filters & Sorts Skill
You help users add filters and sorts to their views so they see the most relevant records.
## Tools
- get_views - List existing views to find the one to modify
- get_view_query_parameters - Check existing filters and sorts on a view
- list_object_metadata_items - Discover fields and their types to build valid filters
- create_view_filter / create_many_view_filters - Add filters to a view
- create_view_sort / create_many_view_sorts - Add sorts to a view
- navigate_app - Navigate to the view to show results
## Filter Operators by Field Type
| Field Type | Available Operators |
|---|---|
| TEXT, EMAILS, FULL_NAME, ADDRESS, LINKS, PHONES | CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY |
| NUMBER, NUMERIC | IS, IS_NOT, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY |
| CURRENCY | GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL, IS_EMPTY, IS_NOT_EMPTY |
| DATE, DATE_TIME | IS, IS_RELATIVE, IS_IN_PAST, IS_IN_FUTURE, IS_TODAY, IS_BEFORE, IS_AFTER, IS_EMPTY, IS_NOT_EMPTY |
| SELECT | IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY |
| MULTI_SELECT, ARRAY | CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY |
| RELATION | IS, IS_NOT, IS_EMPTY, IS_NOT_EMPTY |
| BOOLEAN | IS |
## Sort Directions
- ASC: Ascending (A→Z, 0→9, oldest→newest)
- DESC: Descending (Z→A, 9→0, newest→oldest)
## Filter Groups (AND/OR/NOT)
Filters can be grouped with logical operators:
- **AND**: All filters must match (default)
- **OR**: At least one filter must match
- **NOT**: Negate the group
- Groups can be nested for complex conditions like: name CONTAINS "tech" AND (revenue > 1M OR employees > 100)
## Workflow
1. **Identify the view**: If the user didn't specify a view, ask which view they want to filter/sort. Use get_views to list available views and present them.
2. **Understand the need**: If the user hasn't described what they want to see, ask them. Give guidance with examples:
- "What records do you want to focus on? For example:"
- "Show only high-value opportunities (amount > $50K)"
- "Show companies in a specific city or industry"
- "Show tasks due this week, sorted by priority"
- "Show people from a specific company"
- "Show recent records created in the last 30 days"
3. **Inspect the view**: Use get_view_query_parameters to see existing filters/sorts and list_object_metadata_items to discover available fields.
4. **Build filters**: Based on the user's need, determine:
- Which field(s) to filter on
- Which operator is valid for that field type (see table above)
- What value to filter by
- Whether to use AND or OR grouping for multiple filters
5. **Build sorts**: Determine:
- Which field to sort by (most relevant to the user's goal)
- Direction: ASC or DESC
- Multiple sorts can be added (primary, secondary, etc.)
6. **Apply and navigate**: Create the filters/sorts on the view and navigate to it.
## Common Filter Patterns
### By Time
- Recent records: DATE_TIME field + IS_AFTER + a date value
- Upcoming deadlines: DATE field + IS_IN_FUTURE
- Overdue tasks: DATE field + IS_IN_PAST + status IS_NOT "DONE"
- This week/month: DATE field + IS_RELATIVE
### By Status/Stage
- Open opportunities: stage IS "IN_PROGRESS" or IS_NOT "WON"/"LOST"
- Active tasks: status IS_NOT "DONE"
### By Relationship
- Records linked to a company: company relation IS [specific company]
- Unassigned tasks: assignee IS_EMPTY
- Orphaned records: relation field IS_EMPTY
### By Value
- High-value deals: amount GREATER_THAN_OR_EQUAL threshold
- Large companies: employees GREATER_THAN_OR_EQUAL threshold
## Common Sort Patterns
- Pipeline view: Sort by amount DESC (biggest deals first)
- Task management: Sort by dueAt ASC (earliest due first)
- Recent activity: Sort by updatedAt DESC or createdAt DESC
- Alphabetical: Sort by name ASC
## Composite Fields
Some fields have sub-fields that can be filtered:
- CURRENCY: Use subFieldName "amountMicros" for the numeric value
- ADDRESS: Use subFieldName like "addressCity", "addressCountry"
- FULL_NAME: Use subFieldName like "firstName", "lastName"
- EMAILS: Use the primary email
- LINKS: Use the primary link URL
## Approach
- Always check field types before suggesting operators — using an invalid operator for a field type will fail
- When the user says "show me X", translate that into the appropriate filter logic
- Suggest sorts that complement the filters (e.g., if filtering overdue tasks, sort by dueAt ASC)
- Explain what the filters do so users understand the results
- If complex filtering is needed (AND + OR), explain the logic clearly`,
isCustom: false,
},
}),
'custom-objects-cleanup': (args: Omit<CreateStandardSkillArgs, 'context'>) =>
createStandardSkillFlatMetadata({
...args,
context: {
skillName: 'custom-objects-cleanup',
name: 'custom-objects-cleanup',
label: 'Custom Objects Cleanup',
description:
'Archiving custom objects from a workspace (e.g. dev seed objects like pets, rockets)',
icon: 'IconArchive',
content: `# Custom Objects Cleanup Skill
You help users archive custom objects from their workspace, such as objects created by the dev seed (pets, rockets, survey results, etc.) or any other custom objects.
## Tools
- list_object_metadata_items - List all objects in the workspace to identify custom ones
- update_many_object_metadata - Archive custom objects by setting isActive to false
## Workflow
1. **List all objects**: Use list_object_metadata_items to get the full list of objects in the workspace.
2. **Identify custom objects**: Filter the results to find objects where isCustom is true. These are the objects that were created by users or by the dev seed, as opposed to standard built-in objects (Company, Person, Opportunity, Task, Note, etc.).
3. **Present findings**: Tell the user which custom objects were found. If none are found, inform the user that the workspace has no custom objects.
4. **Confirm before archiving**: List the custom objects you found and ask the user to confirm which ones they want to archive. Present them clearly with their name, label, and description.
5. **Archive confirmed objects**: Use update_many_object_metadata to set isActive to false on all confirmed objects in a single batch call.
6. **Report results**: After archiving is complete, summarize what was archived.
## Important Notes
- Only objects with isCustom = true can be archived. Standard objects cannot be archived through this skill.
- Archiving an object hides it from the workspace but does not delete its fields, relations, or records.
- When called directly by a user, confirm before archiving. When called by another skill (e.g. workspace-demo-seeding), proceed without confirmation.
## Approach
- Be clear about what will be archived and that it is reversible
- If an object has relations to other objects, mention this before archiving
- Archive all confirmed objects in a single batch call using update_many_object_metadata`,
isCustom: false,
},
}),
pptx: (args: Omit<CreateStandardSkillArgs, 'context'>) =>
createStandardSkillFlatMetadata({
...args,
@@ -22,7 +22,9 @@ const addDashboardWidgetSchema = z.object({
.string()
.uuid()
.optional()
.describe('Required for GRAPH widgets: object UUID to aggregate'),
.describe(
'Required for GRAPH and RECORD_TABLE widgets: object UUID to aggregate or display',
),
configuration: widgetConfigurationSchema,
});
@@ -36,7 +38,9 @@ export const createAddDashboardWidgetTool = (
Use get_dashboard first to get pageLayoutTabId and existing widget positions.
Use list_object_metadata_items to get objectMetadataId and field IDs for GRAPH widgets.
See create_complete_dashboard for configuration examples.`,
For RECORD_TABLE widgets: create a dedicated view first with create_view (type TABLE), then pass its viewId in configuration. Never reuse an existing record index view.
See create_complete_dashboard for full configuration examples.`,
inputSchema: addDashboardWidgetSchema,
execute: async (parameters: {
pageLayoutTabId: string;
@@ -25,7 +25,7 @@ const widgetSchema = z.object({
.uuid()
.optional()
.describe(
'REQUIRED for GRAPH widgets: UUID of the object to aggregate (e.g., opportunity, company)',
'REQUIRED for GRAPH and RECORD_TABLE widgets: UUID of the object to aggregate or display',
),
configuration: widgetConfigurationSchema,
});
@@ -82,6 +82,14 @@ WIDGET TYPES:
6. STANDALONE_RICH_TEXT: { type: "STANDALONE_RICH_TEXT", configuration: { configurationType: "STANDALONE_RICH_TEXT", body: { ... } } }
7. RECORD_TABLE: displays a live, filterable record list directly on the dashboard.
- IMPORTANT: you MUST create a dedicated view for the widget BEFORE creating the widget. Use create_view to create a new TABLE view for the object, then pass its ID as viewId. Never reuse an existing index-page view — widget views and record index views must not overlap.
- Requires: objectMetadataId (top-level, UUID of the object to display) AND configuration.viewId (UUID of the dedicated view you just created)
- configuration.configurationType must be "RECORD_TABLE"
- Recommended size: rowSpan 8-10, columnSpan 12 (full width)
- Workflow: (1) call create_view with type TABLE for the object → get the viewId, (2) call create_many_view_fields to add visible columns to that view, (3) create the widget with that viewId
- Example: { type: "RECORD_TABLE", objectMetadataId: "<object-uuid>", configuration: { configurationType: "RECORD_TABLE", viewId: "<dedicated-view-uuid>" } }
AGGREGATION OPERATIONS: COUNT, SUM, AVG, MIN, MAX, COUNT_EMPTY, COUNT_NOT_EMPTY`,
inputSchema: createCompleteDashboardSchema,
execute: async (parameters: {
@@ -191,6 +191,7 @@ export const widgetTypeSchema = z.enum([
WidgetType.GRAPH,
WidgetType.IFRAME,
WidgetType.STANDALONE_RICH_TEXT,
WidgetType.RECORD_TABLE,
]);
// Graph configuration schema for AGGREGATE type (KPI numbers)
@@ -380,6 +381,16 @@ const pieChartConfigSchema = withManualSortRefinement(
}),
);
// Record table configuration
const recordTableConfigSchema = z.object({
configurationType: z.literal(WidgetConfigurationType.RECORD_TABLE),
viewId: z
.uuid()
.describe(
'UUID of the dedicated view created for this widget. Must be created with create_view before creating the widget. Never reuse a record index view.',
),
});
// Iframe configuration
const iframeConfigSchema = z.object({
configurationType: z.literal(WidgetConfigurationType.IFRAME),
@@ -439,6 +450,7 @@ export const widgetConfigurationSchema = z
pieChartConfigSchema,
iframeConfigSchema,
richTextConfigSchema,
recordTableConfigSchema,
])
.optional()
.describe('Widget configuration - structure depends on widget type');
@@ -451,6 +463,7 @@ export const widgetConfigurationSchemaWithoutDefaults = z
pieChartConfigSchemaWithoutDefaults,
iframeConfigSchema,
richTextConfigSchema,
recordTableConfigSchema,
])
.optional()
.describe('Widget configuration - structure depends on widget type');