Compare commits

...

7 Commits

Author SHA1 Message Date
Etienne edbdca614c add 2026-06-05 18:28:41 +02:00
Etienne 70ada24a1d add 2026-06-05 18:27:59 +02:00
Etienne 78ea9d8eeb add 2026-06-05 17:33:59 +02:00
Etienne ce32c76ab9 Merge branch 'main' into ej/optimize-metadata-crud 2026-06-05 15:58:10 +02:00
Etienne 92a9c3a4d4 update 2026-06-05 14:15:26 +02:00
Etienne fa408c9126 add 2026-06-04 18:46:06 +02:00
Etienne fba8d15f8b metada-cud-optim 2026-06-04 13:58:12 +02:00
22 changed files with 656 additions and 213 deletions
@@ -26,6 +26,8 @@ export const buildMcpServerInstructions = (
` ACTION: http_request | send_email | draft_email | navigate_app | code_interpreter | search_help_center`,
` WORKFLOW: create_complete_workflow | create/update/delete_workflow_version_step | activate/deactivate_workflow_version`,
` METADATA: get/create/update/delete_object_metadata | get/create/update/delete_field_metadata`,
` Both GET tools return system items as compact summaries by default; set includeFullSystemObjects / includeFullSystemFields=true for full payload`,
` Both wrap results in {workspaceId, applicationId, ...}`,
` VIEW: get_views | get_view_query_parameters | create/update/delete_view | manage view fields, filters, sorts`,
` DASHBOARD: list_dashboards | get_dashboard | create_complete_dashboard | add/update/delete_dashboard_widget`,
` WEBHOOK: list/create/update/delete_webhook`,
@@ -8,6 +8,7 @@ import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfa
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
@@ -158,7 +159,7 @@ export class ActionToolProvider implements ToolProvider {
category: ToolCategory.ACTION,
icon: 'IconPlayerPlay',
...(includeSchemas && {
inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType),
inputSchema: toToolJsonSchema(tool.inputSchema as z.ZodType),
}),
executionRef: { kind: 'static', toolId },
};
@@ -33,7 +33,6 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { ToolCategory } from 'twenty-shared/ai';
import z from 'zod';
@Injectable()
export class DatabaseToolProvider implements ToolProvider {
@@ -133,7 +132,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. Filter fields are top-level arguments — pass each field as its own key (e.g. { id: { eq: "record-id" } }, or { name: { firstName: { ilike: "%ada%" } } }); do NOT wrap them in a "filter" object and do NOT place a bare operator like "ilike"/"eq" at the top level. Combine conditions with and/or/not. Returns an array of matching records with their full data.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`find_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
}),
@@ -152,7 +151,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Retrieve a single ${objectMetadata.labelSingular} by ID.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`find_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
inputSchema: toToolJsonSchema(FindOneToolInputSchema),
}),
executionRef: {
kind: 'database_crud',
@@ -202,7 +201,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`create_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
}),
@@ -221,7 +220,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Create multiple ${objectMetadata.labelPlural} records in a single call. Provide an array of records, each containing the required fields. Maximum 20 records per call. Returns the created records.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`create_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
@@ -243,7 +242,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`update_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
}),
@@ -262,7 +261,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Apply the SAME field values to all ${objectMetadata.labelPlural} records matching a filter. Use when every matched record gets identical changes (e.g. bulk status change). For records that each have different data to update, use upsert_many_${snakePlural} instead. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`update_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
restrictedFields,
@@ -284,7 +283,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Insert or update multiple ${objectMetadata.labelPlural} records in a single call, where each record has its own individual data. Use this instead of update_many_${snakePlural} when records need different field values. Existing records are matched by unique fields and updated; records with no match are created. Maximum 20 records per call. Returns the upserted records.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`upsert_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
@@ -17,7 +17,9 @@ export const learnToolsInputSchema = z.object({
.array(learnToolsAspectSchema)
.optional()
.default(['description', 'schema'])
.describe('What to learn: description, schema, or both.'),
.describe(
'What to learn: ["description"], ["schema"], or ["description", "schema"].',
),
});
export type LearnToolsInput = z.infer<typeof learnToolsInputSchema>;
@@ -0,0 +1,138 @@
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
describe('compactMetadataOutput', () => {
it('should strip keys with null values when listed in stripWhenNullish', () => {
const record = {
id: '123',
name: 'test',
description: null,
icon: null,
label: 'Test',
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description', 'icon'],
});
expect(result).toEqual({
id: '123',
name: 'test',
label: 'Test',
});
});
it('should strip keys with undefined values when listed in stripWhenNullish', () => {
const record = {
id: '123',
options: undefined,
settings: undefined,
label: 'Test',
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['options', 'settings'],
});
expect(result).toEqual({
id: '123',
label: 'Test',
});
});
it('should not strip keys with truthy values', () => {
const record = {
id: '123',
description: 'A description',
options: [{ label: 'A', value: 'A' }],
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description', 'options'],
});
expect(result).toEqual({
id: '123',
description: 'A description',
options: [{ label: 'A', value: 'A' }],
});
});
it('should strip keys with false values when listed in stripWhenFalse', () => {
const record = {
id: '123',
isLabelSyncedWithName: false,
isUIReadOnly: false,
isActive: true,
};
const result = compactMetadataOutput(record, {
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
});
expect(result).toEqual({
id: '123',
isActive: true,
});
});
it('should not strip keys with true values when listed in stripWhenFalse', () => {
const record = {
id: '123',
isLabelSyncedWithName: true,
isUIReadOnly: true,
};
const result = compactMetadataOutput(record, {
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
});
expect(result).toEqual({
id: '123',
isLabelSyncedWithName: true,
isUIReadOnly: true,
});
});
it('should apply both stripWhenNullish and stripWhenFalse together', () => {
const record = {
id: '123',
name: 'test',
description: null,
icon: 'IconStar',
isLabelSyncedWithName: false,
isUIReadOnly: true,
options: null,
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description', 'options'],
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
});
expect(result).toEqual({
id: '123',
name: 'test',
icon: 'IconStar',
isUIReadOnly: true,
});
});
it('should return a copy without modifying the original', () => {
const record = { id: '123', description: null };
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description'],
});
expect(record).toEqual({ id: '123', description: null });
expect(result).toEqual({ id: '123' });
});
it('should handle empty config gracefully', () => {
const record = { id: '123', name: 'test' };
const result = compactMetadataOutput(record, {});
expect(result).toEqual({ id: '123', name: 'test' });
});
});
@@ -0,0 +1,208 @@
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const buildException = (
report: Record<string, unknown[]>,
): WorkspaceMigrationBuilderException => {
return new WorkspaceMigrationBuilderException({
status: 'fail' as const,
report: report as never,
});
};
describe('formatValidationErrors', () => {
it('should format a single error with its identifier', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'bad_field' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] Name must be camelCase (bad_field)',
);
});
it('should group repeated errors and list all identifiers', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'interno' },
},
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'retailer_name' },
},
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'order_date' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] Name must be camelCase (interno, retailer_name, order_date)',
);
});
it('should handle multiple different errors across entities', () => {
const error = buildException({
fieldMetadata: [
{
errors: [
{ code: 'INVALID_NAME', message: 'Name must be camelCase' },
{ code: 'MISSING_TYPE', message: 'Type is required' },
],
flatEntityMinimalInformation: { name: 'bad_field' },
},
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'another_bad' },
},
],
});
const result = formatValidationErrors(error);
expect(result).toContain(
'[fieldMetadata] Name must be camelCase (bad_field, another_bad)',
);
expect(result).toContain('[fieldMetadata] Type is required (bad_field)');
});
it('should fall back to code when message is missing', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'SNAKE_CASE_REQUIRED', message: '' }],
flatEntityMinimalInformation: { name: 'test_field' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] SNAKE_CASE_REQUIRED (test_field)',
);
});
it('should use nameSingular as identifier for object metadata', () => {
const error = buildException({
objectMetadata: [
{
errors: [{ code: 'DUPLICATE', message: 'Object already exists' }],
flatEntityMinimalInformation: { nameSingular: 'invoice' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[objectMetadata] Object already exists (invoice)',
);
});
it('should use label as fallback identifier', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'Something wrong' }],
flatEntityMinimalInformation: { label: 'My Field' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] Something wrong (My Field)',
);
});
it('should handle failures without identifiers', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'Unknown error' }],
flatEntityMinimalInformation: {},
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] Unknown error',
);
});
it('should handle failures without flatEntityMinimalInformation', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'No info' }],
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] No info',
);
});
it('should return error.message when report has no failures', () => {
const error = buildException({
fieldMetadata: [],
objectMetadata: [],
});
expect(formatValidationErrors(error)).toBe(
'Workspace migration builder failed',
);
});
it('should handle multiple entity types', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'Field error' }],
flatEntityMinimalInformation: { name: 'myField' },
},
],
objectMetadata: [
{
errors: [{ code: 'ERR', message: 'Object error' }],
flatEntityMinimalInformation: { nameSingular: 'myObject' },
},
],
});
const result = formatValidationErrors(error);
expect(result).toContain('[fieldMetadata] Field error (myField)');
expect(result).toContain('[objectMetadata] Object error (myObject)');
});
it('should skip entries with no errors array', () => {
const error = buildException({
fieldMetadata: [{ flatEntityMinimalInformation: { name: 'test' } }],
});
expect(formatValidationErrors(error)).toBe(
'Workspace migration builder failed',
);
});
it('should handle large batch with identical errors efficiently', () => {
const failures = Array.from({ length: 10 }, (_, i) => ({
errors: [{ code: 'INVALID', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: `field_${i}` },
}));
const error = buildException({ fieldMetadata: failures });
const result = formatValidationErrors(error);
const lines = result.split('\n');
expect(lines).toHaveLength(2);
expect(lines[1]).toContain('field_0');
expect(lines[1]).toContain('field_9');
});
});
@@ -0,0 +1,25 @@
type CompactConfig = {
stripWhenNullish?: string[];
stripWhenFalse?: string[];
};
export const compactMetadataOutput = (
metadata: Record<string, unknown>,
config: CompactConfig,
): Record<string, unknown> => {
const result = { ...metadata };
for (const key of config.stripWhenNullish ?? []) {
if (result[key] === null || result[key] === undefined) {
delete result[key];
}
}
for (const key of config.stripWhenFalse ?? []) {
if (result[key] === false) {
delete result[key];
}
}
return result;
};
@@ -1,28 +1,68 @@
import type { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const getFailureIdentifier = (failure: {
flatEntityMinimalInformation?: Partial<Record<string, unknown>>;
}): string | undefined => {
const info = failure.flatEntityMinimalInformation;
if (!info) {
return undefined;
}
return (
(info.name as string | undefined) ??
(info.nameSingular as string | undefined) ??
(info.label as string | undefined) ??
(info.id as string | undefined)
);
};
export const formatValidationErrors = (
error: WorkspaceMigrationBuilderException,
): string => {
const report = error.failedWorkspaceMigrationBuildResult.report;
const errorMessages: string[] = [];
const grouped = new Map<string, string[]>();
for (const [entityType, failures] of Object.entries(report)) {
if (Array.isArray(failures) && failures.length > 0) {
for (const failure of failures) {
if (failure.errors && Array.isArray(failure.errors)) {
for (const validationError of failure.errors) {
const message = validationError.message || validationError.code;
if (!Array.isArray(failures) || failures.length === 0) {
continue;
}
errorMessages.push(`[${entityType}] ${message}`);
}
for (const failure of failures) {
if (!failure.errors || !Array.isArray(failure.errors)) {
continue;
}
const identifier = getFailureIdentifier(failure);
for (const validationError of failure.errors) {
const message = validationError.message || validationError.code;
const key = `[${entityType}] ${message}`;
const existing = grouped.get(key) ?? [];
if (identifier) {
existing.push(identifier);
}
grouped.set(key, existing);
}
}
}
if (errorMessages.length === 0) {
if (grouped.size === 0) {
return error.message;
}
return `Validation errors:\n${errorMessages.join('\n')}`;
const lines: string[] = [];
for (const [message, identifiers] of grouped) {
if (identifiers.length > 1) {
lines.push(`${message} (${identifiers.join(', ')})`);
} else if (identifiers.length === 1) {
lines.push(`${message} (${identifiers[0]})`);
} else {
lines.push(message);
}
}
return `Validation errors:\n${lines.join('\n')}`;
};
@@ -2,6 +2,7 @@ import { type ToolSet } from 'ai';
import { z } from 'zod';
import { type ToolCategory } from 'twenty-shared/ai';
import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
@@ -36,9 +37,8 @@ export const toolSetToDescriptors = (
let inputSchema: object;
try {
inputSchema = z.toJSONSchema(tool.inputSchema as z.ZodType);
inputSchema = toToolJsonSchema(tool.inputSchema as z.ZodType);
} catch {
// Fallback: schema is already JSON Schema or another format
inputSchema = (tool.inputSchema ?? {}) as object;
}
@@ -4,104 +4,97 @@ import { type ToolSet } from 'ai';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { z } from 'zod';
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const EXCLUDED_FIELD_NAMES = new Set([
'searchVector',
'deletedAt',
'position',
'updatedBy',
]);
const FIELD_STRIP_WHEN_NULLISH = [
'options',
'settings',
'defaultValue',
'description',
'icon',
];
const FIELD_STRIP_WHEN_FALSE = ['isLabelSyncedWithName', 'isUIReadOnly'];
const GetFieldMetadataInputSchema = z.object({
id: z
.string()
.uuid()
.optional()
.describe(
'Unique identifier for the field metadata. If provided, returns a single field.',
),
.describe('Field ID. Returns one field if set.'),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('Filter fields by object metadata ID.'),
.describe('Filter by object ID.'),
includeFullSystemFields: z
.boolean()
.default(false)
.describe(
'Return full payload for system fields. Default: false — system fields are returned as compact {id, name, type}.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of fields to return.'),
.describe('Max fields to return.'),
});
const CreateFieldMetadataInputSchema = z.object({
objectMetadataId: z
.string()
.uuid()
.describe('ID of the object to add the field to'),
type: z
.nativeEnum(FieldMetadataType)
.describe(
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
),
name: z.string().describe('Internal name of the field (camelCase)'),
label: z.string().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isNullable: z.boolean().optional().describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('Whether the field value must be unique'),
defaultValue: z.unknown().optional().describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
objectMetadataId: z.string().uuid().describe('Target object ID'),
type: z.nativeEnum(FieldMetadataType).describe('Field type'),
name: z.string().describe('Field name (camelCase)'),
label: z.string().describe('Display label'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
isNullable: z.boolean().optional().describe('Nullable'),
isUnique: z.boolean().optional().describe('Unique constraint'),
defaultValue: z.unknown().optional().describe('Default value'),
options: z.unknown().optional().describe('SELECT/MULTI_SELECT options'),
settings: z.unknown().optional().describe('Field settings'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
isRemoteCreation: z
.boolean()
.optional()
.describe('Whether this is a remote field creation'),
.describe('Sync label with name'),
isRemoteCreation: z.boolean().optional().describe('Remote field creation'),
relationCreationPayload: z
.unknown()
.optional()
.describe('Payload for creating relation fields'),
.describe('Relation creation payload'),
});
const UpdateFieldMetadataInputSchema = z.object({
id: z.string().uuid().describe('ID of the field to update'),
name: z.string().optional().describe('Internal name of the field'),
label: z.string().optional().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isActive: z.boolean().optional().describe('Whether the field is active'),
isNullable: z.boolean().optional().describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('Whether the field value must be unique'),
defaultValue: z.unknown().optional().describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
id: z.string().uuid().describe('Field ID'),
name: z.string().optional().describe('Field name'),
label: z.string().optional().describe('Display label'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
isActive: z.boolean().optional().describe('Active state'),
isNullable: z.boolean().optional().describe('Nullable'),
isUnique: z.boolean().optional().describe('Unique constraint'),
defaultValue: z.unknown().optional().describe('Default value'),
options: z.unknown().optional().describe('SELECT/MULTI_SELECT options'),
settings: z.unknown().optional().describe('Field settings'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
.describe('Sync label with name'),
});
const DeleteFieldMetadataInputSchema = z.object({
id: z.string().uuid().describe('ID of the field to delete'),
id: z.string().uuid().describe('Field ID'),
});
const CreateManyFieldMetadataInputSchema = z.object({
@@ -109,7 +102,7 @@ const CreateManyFieldMetadataInputSchema = z.object({
.array(CreateFieldMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of field metadata to create (1-20 items).'),
.describe('Fields to create (max 20).'),
});
const UpdateManyFieldMetadataInputSchema = z.object({
@@ -117,49 +110,27 @@ const UpdateManyFieldMetadataInputSchema = z.object({
.array(UpdateFieldMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of field metadata updates to apply (1-20 items).'),
.describe('Fields to update (max 20).'),
});
const CreateManyRelationFieldsInputSchema = z.object({
relations: z
.array(
z.object({
objectMetadataId: z
.string()
.uuid()
.describe('ID of the source object to add the relation field to'),
name: z
.string()
.describe('Internal name of the relation field (camelCase)'),
label: z.string().describe('Display label of the relation field'),
description: z
.string()
.optional()
.describe('Description of the relation field'),
icon: z
.string()
.optional()
.describe('Icon identifier for the relation field'),
type: z
.nativeEnum(RelationType)
.describe('Relation type: MANY_TO_ONE or ONE_TO_MANY'),
targetObjectMetadataId: z
.string()
.uuid()
.describe('ID of the target object this relation points to'),
targetFieldLabel: z
.string()
.describe(
'Display label for the inverse relation field on the target object',
),
targetFieldIcon: z
.string()
.describe('Icon for the inverse relation field (e.g. IconSomething)'),
objectMetadataId: z.string().uuid().describe('Source object ID'),
name: z.string().describe('Field name (camelCase)'),
label: z.string().describe('Display label'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
type: z.nativeEnum(RelationType).describe('MANY_TO_ONE or ONE_TO_MANY'),
targetObjectMetadataId: z.string().uuid().describe('Target object ID'),
targetFieldLabel: z.string().describe('Inverse field label'),
targetFieldIcon: z.string().describe('Inverse field icon'),
}),
)
.min(1)
.max(20)
.describe('Array of relation fields to create (1-20 items).'),
.describe('Relations to create (max 20).'),
});
@Injectable()
@@ -170,14 +141,15 @@ export class FieldMetadataToolsFactory {
return {
get_field_metadata: {
description:
'Find fields metadata. Retrieve information about the fields of objects in the workspace data model.',
'Retrieve field metadata. Returns an array of fields. System fields are compact {id, name, type} by default; set includeFullSystemFields=true for full payload. Internal fields (searchVector, deletedAt, position, updatedBy) are excluded.',
inputSchema: GetFieldMetadataInputSchema,
execute: async (parameters: {
id?: string;
objectMetadataId?: string;
includeFullSystemFields?: boolean;
limit?: number;
}) => {
return this.fieldMetadataService.query({
const rawResults = await this.fieldMetadataService.query({
filter: {
workspaceId: { eq: workspaceId },
...(parameters.id ? { id: { eq: parameters.id } } : {}),
@@ -189,11 +161,34 @@ export class FieldMetadataToolsFactory {
},
paging: { limit: parameters.limit ?? 100 },
});
const compactedFields = (
rawResults as unknown as Record<string, unknown>[]
)
.filter((field) => !EXCLUDED_FIELD_NAMES.has(field.name as string))
.map((field) => {
if (field.isSystem && !parameters.includeFullSystemFields) {
return {
id: field.id,
name: field.name,
type: field.type,
};
}
return compactMetadataOutput(
{ ...field },
{
stripWhenNullish: FIELD_STRIP_WHEN_NULLISH,
stripWhenFalse: FIELD_STRIP_WHEN_FALSE,
},
);
});
return compactedFields;
},
},
create_field_metadata: {
description:
'Create a new field metadata on an object. Specify the objectMetadataId and field properties.',
description: 'Create a new field on an object.',
inputSchema: CreateFieldMetadataInputSchema,
execute: async (parameters: {
objectMetadataId: string;
@@ -220,7 +215,13 @@ export class FieldMetadataToolsFactory {
workspaceId,
});
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
return {
id: flatFieldMetadata.id,
name: flatFieldMetadata.name,
label: flatFieldMetadata.label,
type: flatFieldMetadata.type,
objectMetadataId: flatFieldMetadata.objectMetadataId,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
@@ -231,7 +232,7 @@ export class FieldMetadataToolsFactory {
},
update_field_metadata: {
description:
'Update an existing field metadata. Provide the field ID and the properties to update.',
'Update a field. Provide field ID and properties to change.',
inputSchema: UpdateFieldMetadataInputSchema,
execute: async (parameters: {
id: string;
@@ -258,7 +259,12 @@ export class FieldMetadataToolsFactory {
workspaceId,
});
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
return {
id: flatFieldMetadata.id,
name: flatFieldMetadata.name,
label: flatFieldMetadata.label,
type: flatFieldMetadata.type,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
@@ -268,7 +274,7 @@ export class FieldMetadataToolsFactory {
},
},
delete_field_metadata: {
description: 'Delete a field metadata by its ID.',
description: 'Delete a field by ID.',
inputSchema: DeleteFieldMetadataInputSchema,
execute: async (parameters: { id: string }) => {
try {
@@ -278,7 +284,7 @@ export class FieldMetadataToolsFactory {
workspaceId,
});
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
return { id: flatFieldMetadata.id, success: true };
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
@@ -368,8 +374,7 @@ export class FieldMetadataToolsFactory {
},
},
create_many_relation_fields: {
description:
'Create multiple relation fields between objects at once. This is the recommended way to add relations after creating objects and non-relation fields. Each item specifies the source object, target object, relation type, and labels for both sides of the relation.',
description: 'Create multiple relation fields between objects at once.',
inputSchema: CreateManyRelationFieldsInputSchema,
execute: async (parameters: {
relations: Array<{
@@ -18,7 +18,6 @@ const commonOptionalFields = {
.optional()
.describe('Position among siblings; defaults to the end.'),
folderId: z
.string()
.uuid()
.optional()
.describe('Parent folder id, if the item should live inside a folder.'),
@@ -84,7 +83,7 @@ const createNavigationMenuItemSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal(NavigationMenuItemType.PAGE_LAYOUT),
scope: navigationMenuItemScopeSchema,
pageLayoutId: z.string().uuid().describe('Id of the page layout to pin'),
pageLayoutId: z.string().describe('Id of the page layout to pin'),
name: requiredNameField,
...commonOptionalFields,
}),
@@ -4,7 +4,7 @@ import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
const deleteNavigationMenuItemSchema = z.object({
id: z.string().uuid().describe('Id of the navigation menu item to delete'),
id: z.uuid().describe('Id of the navigation menu item to delete'),
});
type DeleteNavigationMenuItemParams = z.infer<
@@ -16,7 +16,6 @@ const listNavigationMenuItemsSchema = z.object({
"'workspace' = shared navigation, 'user' = current user's favorites, 'all' = both merged (default).",
),
folderId: z
.string()
.uuid()
.optional()
.describe('Only return items inside this folder.'),
@@ -5,7 +5,7 @@ import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
const updateNavigationMenuItemSchema = z.object({
id: z.string().uuid().describe('Id of the navigation menu item to update'),
id: z.uuid().describe('Id of the navigation menu item to update'),
name: z
.string()
.trim()
@@ -19,7 +19,6 @@ const updateNavigationMenuItemSchema = z.object({
position: z.number().optional().describe('New position among siblings'),
folderId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
@@ -32,7 +31,6 @@ const updateNavigationMenuItemSchema = z.object({
.describe('New URL (only meaningful for LINK items)'),
pageLayoutId: z
.string()
.uuid()
.optional()
.describe('New page layout id (only meaningful for PAGE_LAYOUT items)'),
});
@@ -3,18 +3,33 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const OBJECT_STRIP_WHEN_NULLISH = [
'standardOverrides',
'color',
'duplicateCriteria',
'shortcut',
'imageIdentifierFieldMetadataId',
'description',
'icon',
];
const GetObjectMetadataInputSchema = z.object({
id: z
.string()
.uuid()
.optional()
.describe('Object ID. Returns one object if set.'),
includeFullSystemObjects: z
.boolean()
.default(false)
.describe(
'Unique identifier for the object metadata. If provided, returns a single object.',
'Return full payload for system objects. Default: false — system objects are returned as compact {id, nameSingular, namePlural}.',
),
limit: z
.number()
@@ -22,63 +37,52 @@ const GetObjectMetadataInputSchema = z.object({
.min(1)
.max(100)
.default(100)
.describe('Maximum number of objects to return.'),
.describe('Max objects to return.'),
});
const CreateObjectMetadataInputSchema = z.object({
nameSingular: z
.string()
.describe('Singular name for the object (e.g., "company")'),
namePlural: z
.string()
.describe('Plural name for the object (e.g., "companies")'),
labelSingular: z
.string()
.describe('Display label in singular form (e.g., "Company")'),
labelPlural: z
.string()
.describe('Display label in plural form (e.g., "Companies")'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
isRemote: z.boolean().optional().describe('Whether this is a remote object'),
nameSingular: z.string().describe('Singular name (e.g. "company")'),
namePlural: z.string().describe('Plural name (e.g. "companies")'),
labelSingular: z.string().describe('Singular label (e.g. "Company")'),
labelPlural: z.string().describe('Plural label (e.g. "Companies")'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
shortcut: z.string().optional().describe('Keyboard shortcut'),
isRemote: z.boolean().optional().describe('Remote object'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
.describe('Sync label with name'),
});
const UpdateObjectMetadataInputSchema = z.object({
id: z.string().uuid().describe('ID of the object to update'),
labelSingular: z
.string()
.optional()
.describe('Display label in singular form'),
labelPlural: z.string().optional().describe('Display label in plural form'),
nameSingular: z.string().optional().describe('Singular name for the object'),
namePlural: z.string().optional().describe('Plural name for the object'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
isActive: z.boolean().optional().describe('Whether the object is active'),
id: z.uuid().describe('Object ID'),
labelSingular: z.string().optional().describe('Singular label'),
labelPlural: z.string().optional().describe('Plural label'),
nameSingular: z.string().optional().describe('Singular name'),
namePlural: z.string().optional().describe('Plural name'),
description: z.string().optional().describe('Description'),
icon: z.string().optional().describe('Icon name'),
shortcut: z.string().optional().describe('Keyboard shortcut'),
isActive: z.boolean().optional().describe('Active state'),
labelIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as label identifier'),
.describe('Label identifier field ID'),
imageIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as image identifier'),
.describe('Image identifier field ID'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
.describe('Sync label with name'),
});
const DeleteObjectMetadataInputSchema = z.object({
id: z.string().uuid().describe('ID of the object to delete'),
id: z.string().uuid().describe('Object ID'),
});
const CreateManyObjectMetadataInputSchema = z.object({
@@ -86,7 +90,7 @@ const CreateManyObjectMetadataInputSchema = z.object({
.array(CreateObjectMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of object metadata to create (1-20 items).'),
.describe('Objects to create (max 20).'),
});
const UpdateManyObjectMetadataInputSchema = z.object({
@@ -94,7 +98,7 @@ const UpdateManyObjectMetadataInputSchema = z.object({
.array(UpdateObjectMetadataInputSchema)
.min(1)
.max(20)
.describe('Array of object metadata updates to apply (1-20 items).'),
.describe('Objects to update (max 20).'),
});
@Injectable()
@@ -105,9 +109,13 @@ export class ObjectMetadataToolsFactory {
return {
get_object_metadata: {
description:
'Find objects metadata. Retrieve information about the data model objects in the workspace.',
'Retrieve object metadata. Returns an array of objects. System objects are compact {id, nameSingular, namePlural} by default; set includeFullSystemObjects=true for full payload.',
inputSchema: GetObjectMetadataInputSchema,
execute: async (parameters: { id?: string; limit?: number }) => {
execute: async (parameters: {
id?: string;
includeFullSystemObjects?: boolean;
limit?: number;
}) => {
const flatObjectMetadatas =
await this.objectMetadataService.findManyWithinWorkspace(
workspaceId,
@@ -117,14 +125,27 @@ export class ObjectMetadataToolsFactory {
},
);
return flatObjectMetadatas.map((flatObjectMetadata) =>
fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata),
);
return flatObjectMetadatas.map((flatObjectMetadata) => {
const dto =
fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata);
if (dto.isSystem && !parameters.includeFullSystemObjects) {
return {
id: dto.id,
nameSingular: dto.nameSingular,
namePlural: dto.namePlural,
};
}
return compactMetadataOutput(
{ ...dto },
{ stripWhenNullish: OBJECT_STRIP_WHEN_NULLISH },
);
});
},
},
create_object_metadata: {
description:
'Create a new object metadata in the workspace data model.',
description: 'Create a new object in the workspace data model.',
inputSchema: CreateObjectMetadataInputSchema,
execute: async (parameters: {
nameSingular: string;
@@ -146,9 +167,11 @@ export class ObjectMetadataToolsFactory {
workspaceId,
});
return fromFlatObjectMetadataToObjectMetadataDto(
flatObjectMetadata,
);
return {
id: flatObjectMetadata.id,
nameSingular: flatObjectMetadata.nameSingular,
labelSingular: flatObjectMetadata.labelSingular,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
@@ -159,7 +182,7 @@ export class ObjectMetadataToolsFactory {
},
update_object_metadata: {
description:
'Update an existing object metadata. Provide the object ID and the fields to update.',
'Update an object. Provide object ID and properties to change.',
inputSchema: UpdateObjectMetadataInputSchema,
execute: async (parameters: {
id: string;
@@ -184,9 +207,11 @@ export class ObjectMetadataToolsFactory {
workspaceId,
});
return fromFlatObjectMetadataToObjectMetadataDto(
flatObjectMetadata,
);
return {
id: flatObjectMetadata.id,
nameSingular: flatObjectMetadata.nameSingular,
labelSingular: flatObjectMetadata.labelSingular,
};
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
@@ -196,8 +221,7 @@ export class ObjectMetadataToolsFactory {
},
},
delete_object_metadata: {
description:
'Delete an object metadata by its ID. This will also delete all associated fields.',
description: 'Delete an object by ID. Also deletes associated fields.',
inputSchema: DeleteObjectMetadataInputSchema,
execute: async (parameters: { id: string }) => {
try {
@@ -207,9 +231,7 @@ export class ObjectMetadataToolsFactory {
workspaceId,
});
return fromFlatObjectMetadataToObjectMetadataDto(
flatObjectMetadata,
);
return { id: flatObjectMetadata.id, success: true };
} catch (error) {
if (error instanceof WorkspaceMigrationBuilderException) {
throw new Error(formatValidationErrors(error));
@@ -220,7 +242,7 @@ 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.',
'Create multiple objects at once. Batch version of create_object_metadata.',
inputSchema: CreateManyObjectMetadataInputSchema,
execute: async (parameters: {
objects: Array<{
@@ -258,7 +280,7 @@ export class ObjectMetadataToolsFactory {
},
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.',
'Update multiple objects at once. Batch version of update_object_metadata.',
inputSchema: UpdateManyObjectMetadataInputSchema,
execute: async (parameters: {
objects: Array<{
@@ -26,7 +26,7 @@ const CreateViewFilterInputSchema = z.object({
.string()
.uuid()
.describe(
'ID of the field to filter on. Use list_object_metadata_items to find field IDs.',
'ID of the field to filter on. Use get_field_metadata to find field IDs.',
),
operand: z
.enum(VIEW_FILTER_OPERAND_OPTIONS)
@@ -122,7 +122,7 @@ export class ViewFilterToolsFactory {
return {
create_view_filter: {
description:
'Add a filter to a view. Use list_object_metadata_items to get fieldMetadataId values.',
'Add a filter to a view. Use get_field_metadata to get fieldMetadataId values.',
inputSchema: CreateViewFilterInputSchema,
execute: async (parameters: {
viewId: string;
@@ -161,7 +161,7 @@ export class ViewFilterToolsFactory {
},
create_many_view_filters: {
description:
'Add multiple filters to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
'Add multiple filters to a view in one call. Use get_field_metadata to get fieldMetadataId values.',
inputSchema: CreateManyViewFiltersInputSchema,
execute: async (parameters: {
filters: Array<{
@@ -23,7 +23,7 @@ const CreateViewSortInputSchema = z.object({
.string()
.uuid()
.describe(
'ID of the field to sort by. Use list_object_metadata_items to find field IDs.',
'ID of the field to sort by. Use get_field_metadata to find field IDs.',
),
direction: z
.enum(VIEW_SORT_DIRECTION_OPTIONS)
@@ -82,7 +82,7 @@ export class ViewSortToolsFactory {
return {
create_view_sort: {
description:
'Add a sort to a view. Use list_object_metadata_items to get fieldMetadataId values.',
'Add a sort to a view. Use get_field_metadata to get fieldMetadataId values.',
inputSchema: CreateViewSortInputSchema,
execute: async (parameters: {
viewId: string;
@@ -116,7 +116,7 @@ export class ViewSortToolsFactory {
},
create_many_view_sorts: {
description:
'Add multiple sorts to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
'Add multiple sorts to a view in one call. Use get_field_metadata to get fieldMetadataId values.',
inputSchema: CreateManyViewSortsInputSchema,
execute: async (parameters: {
sorts: Array<{
@@ -4,7 +4,7 @@ import { type WebhookToolContext } from 'src/engine/metadata-modules/webhook/too
import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-dependencies.type';
const deleteWebhookSchema = z.object({
id: z.string().uuid().describe('The id of the webhook to delete'),
id: z.uuid().describe('The id of the webhook to delete'),
});
type DeleteWebhookParams = z.infer<typeof deleteWebhookSchema>;
@@ -6,7 +6,7 @@ import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhoo
import { compileWebhookOperations } from 'src/engine/metadata-modules/webhook/tools/utils/compile-webhook-operations.util';
const updateWebhookSchema = z.object({
id: z.string().uuid().describe('The id of the webhook to update'),
id: z.uuid().describe('The id of the webhook to update'),
targetUrl: z
.string()
.url()
@@ -182,7 +182,7 @@ For the fields you will create, make sure to create a good variety of field type
*Here are the steps to follow closely:*
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 get_object_metadata to see all available objects in the workspace
- Use find_many_people (limit: 5) and find_many_companies (limit: 5) and find_many_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")
@@ -201,7 +201,7 @@ 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 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
- New custom fields for the standard objects (Person, Company, Opportunity) — use their objectMetadataId from get_object_metadata
- 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
@@ -332,12 +332,12 @@ You help users create and manage dashboards with widgets.
- list_dashboards, get_dashboard
- create_complete_dashboard
- add_dashboard_tab, add_dashboard_widget, update_dashboard_widget, delete_dashboard_widget
- list_object_metadata_items (resolve object + field IDs)
- get_object_metadata / get_field_metadata (resolve object + field IDs)
## Graph Widget Workflow
1. Ask what data the user wants to visualize.
2. Call list_object_metadata_items and resolve objectMetadataId + field IDs.
2. Call get_object_metadata and get_field_metadata to resolve objectMetadataId + field IDs.
3. Always call get_dashboard before modifying widgets.
4. Build the widget configuration using the rules below.
5. Call add_dashboard_widget or update_dashboard_widget. Use activeTabId from context if available.
@@ -358,7 +358,7 @@ You help users create and manage dashboards with widgets.
- Relation to composite field: \`owner.name\` where "name" is FULL_NAME → subFieldName must be "name.firstName" or "name.lastName" (NOT just "name")
- Relation + composite: \`company.address.addressCity\` → subFieldName "address.addressCity"
- **Never omit subFieldName for relation fields** — grouping by ID is almost never useful
- **IMPORTANT**: Check the target field's type from list_object_metadata_items. If it is composite (FULL_NAME, ADDRESS, CURRENCY, EMAILS, PHONES, LINKS), you MUST drill into a specific subfield using dot notation (e.g. "name.firstName", "address.addressCity", "emails.primaryEmail").
- **IMPORTANT**: Check the target field's type from get_field_metadata. If it is composite (FULL_NAME, ADDRESS, CURRENCY, EMAILS, PHONES, LINKS), you MUST drill into a specific subfield using dot notation (e.g. "name.firstName", "address.addressCity", "emails.primaryEmail").
## User Language Notes
@@ -469,6 +469,11 @@ You help users manage their workspace data model by creating, updating, and orga
- **Relations**: Links between objects (one-to-many, many-to-one)
- **Labels vs Names**: Labels are for display, names are internal identifiers (camelCase)
## Tool Output Format
- **get_object_metadata** returns \`{workspaceId, applicationId, objects: [...]}\`. System objects (attachment, message, etc.) are returned as compact \`{id, nameSingular, namePlural}\`. Pass \`includeFullSystemObjects: true\` for the full payload (e.g. when creating relations to workspaceMember).
- **get_field_metadata** returns \`{workspaceId, applicationId, fields: [...]}\`. System fields are returned as compact \`{id, name, type}\`. Pass \`includeFullSystemFields: true\` for the full payload. Internal fields (searchVector, deletedAt, position, updatedBy) are always excluded. Null properties are omitted from non-system fields.
## Field Types Available
- **TEXT**: Simple text fields
@@ -1141,7 +1146,7 @@ You help users create and configure views to organize how they see their records
- 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
- get_object_metadata / get_field_metadata - Discover objects and their fields
- navigate_app - Navigate to a view after creation
## Workflow
@@ -1214,7 +1219,7 @@ You help users add filters and sorts to their views so they see the most relevan
- 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
- get_field_metadata - 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
@@ -1257,7 +1262,7 @@ Filters can be grouped with logical operators:
- "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.
3. **Inspect the view**: Use get_view_query_parameters to see existing filters/sorts and get_field_metadata to discover available fields.
4. **Build filters**: Based on the user's need, determine:
- Which field(s) to filter on
@@ -1336,12 +1341,12 @@ You help users archive custom objects from their workspace, such as objects crea
## Tools
- list_object_metadata_items - List all objects in the workspace to identify custom ones
- get_object_metadata - 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.
1. **List all objects**: Use get_object_metadata 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.).
@@ -36,7 +36,7 @@ export const createAddDashboardWidgetTool = (
description: `Add a widget to an existing dashboard tab.
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.
Use get_object_metadata and get_field_metadata to get objectMetadataId and field IDs for GRAPH widgets.
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.
@@ -51,7 +51,7 @@ export const createCreateCompleteDashboardTool = (
name: 'create_complete_dashboard' as const,
description: `Create a dashboard with layout, tab, and widgets.
IMPORTANT: Before creating GRAPH widgets, you MUST use list_object_metadata_items to get valid objectMetadataId and field IDs.
IMPORTANT: Before creating GRAPH widgets, you MUST use get_object_metadata and get_field_metadata to get valid objectMetadataId and field IDs.
GRID SYSTEM:
- 12 columns (0-11), rows start at 0