+ | Variable
+ | string
+ | null
+ | undefined,
+): FormMultiRecordPickerDraftValue => {
+ if (isArray(defaultValue)) {
+ return {
+ type: 'static',
+ value: keepRecordIdsAndVariables(defaultValue),
+ };
+ }
+
+ if (isNonEmptyString(defaultValue)) {
+ if (isStandaloneVariableString(defaultValue)) {
+ return { type: 'variable', value: defaultValue };
+ }
+
+ if (isValidUuid(defaultValue)) {
+ return { type: 'static', value: [defaultValue] };
+ }
+
+ try {
+ const parsedValue = JSON.parse(defaultValue);
+
+ if (isArray(parsedValue)) {
+ return {
+ type: 'static',
+ value: keepRecordIdsAndVariables(parsedValue),
+ };
+ }
+ } catch {}
+ }
+
+ return { type: 'static', value: [] };
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx
new file mode 100644
index 0000000000..7ba327756a
--- /dev/null
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf.tsx
@@ -0,0 +1,156 @@
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
+import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput';
+import { FormMultiRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormMultiRecordPicker';
+import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
+import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
+import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
+import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
+import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
+import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
+import { getWorkflowCodeFieldsEnumSelectOptions } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsEnumSelectOptions';
+import { getWorkflowCodeFieldsLeafKind } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind';
+import { t } from '@lingui/core/macro';
+import {
+ isBoolean,
+ isNonEmptyArray,
+ isNonEmptyString,
+ isNull,
+ isNumber,
+ isString,
+} from '@sniptt/guards';
+import { isDefined } from 'twenty-shared/utils';
+import { type InputSchemaProperty } from 'twenty-shared/workflow';
+
+type WorkflowEditActionCodeFieldLeafProps = {
+ label: string;
+ inputValue: unknown;
+ schemaProperty?: InputSchemaProperty;
+ readonly?: boolean;
+ onChange: (value: unknown) => void;
+ VariablePicker?: VariablePickerComponent;
+};
+
+export const WorkflowEditActionCodeFieldLeaf = ({
+ label,
+ inputValue,
+ schemaProperty,
+ readonly,
+ onChange,
+ VariablePicker,
+}: WorkflowEditActionCodeFieldLeafProps) => {
+ const { objectMetadataItems } = useObjectMetadataItems();
+
+ const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty);
+
+ if (leafKind === 'record' || leafKind === 'record-array') {
+ const objectUniversalIdentifier =
+ schemaProperty?.objectUniversalIdentifier ??
+ schemaProperty?.items?.objectUniversalIdentifier;
+
+ const recordObjectMetadataItem = isNonEmptyString(objectUniversalIdentifier)
+ ? objectMetadataItems.find(
+ (objectMetadataItem) =>
+ objectMetadataItem.universalIdentifier ===
+ objectUniversalIdentifier,
+ )
+ : undefined;
+
+ if (isDefined(recordObjectMetadataItem)) {
+ if (leafKind === 'record') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+ }
+
+ if (leafKind === 'boolean') {
+ return (
+
+ );
+ }
+
+ if (leafKind === 'number') {
+ return (
+
+ );
+ }
+
+ if (leafKind === 'enum' && isDefined(schemaProperty)) {
+ const enumOptions = getWorkflowCodeFieldsEnumSelectOptions(schemaProperty);
+
+ if (isNonEmptyArray(enumOptions)) {
+ return (
+
+ );
+ }
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx
index 323675355f..9cab4d97b2 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields.tsx
@@ -1,17 +1,12 @@
-import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldInput';
import { FormNestedFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormNestedFieldInputContainer';
-import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
-import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
-import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { InputLabel } from '@/ui/input/components/InputLabel';
+import { WorkflowEditActionCodeFieldLeaf } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFieldLeaf';
import { getInputSchemaPropertyAtPath } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getInputSchemaPropertyAtPath';
-import { getWorkflowCodeFieldsEnumSelectOptions } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsEnumSelectOptions';
import { getWorkflowCodeFieldsLeafKind } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind';
import { styled } from '@linaria/react';
-import { t } from '@lingui/core/macro';
-import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards';
-import { isDefined, isPlainObject } from 'twenty-shared/utils';
+import { isNonEmptyString } from '@sniptt/guards';
+import { isPlainObject } from 'twenty-shared/utils';
import { type FunctionInput, type InputSchema } from 'twenty-shared/workflow';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -59,7 +54,13 @@ export const WorkflowEditActionCodeFields = ({
? schemaProperty.label
: inputKey;
- if (isPlainObject(inputValue)) {
+ const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty);
+ const isNestedObject =
+ isPlainObject(inputValue) &&
+ leafKind !== 'record' &&
+ leafKind !== 'record-array';
+
+ if (isNestedObject) {
return (
{displayLabel}
@@ -78,83 +79,15 @@ export const WorkflowEditActionCodeFields = ({
);
}
- const leafKind = getWorkflowCodeFieldsLeafKind(schemaProperty);
-
- if (leafKind === 'boolean') {
- return (
- onInputChange?.(value, currentPath)}
- VariablePicker={VariablePicker}
- />
- );
- }
-
- if (leafKind === 'number') {
- return (
- onInputChange?.(value, currentPath)}
- VariablePicker={VariablePicker}
- />
- );
- }
-
- if (leafKind === 'enum' && isDefined(schemaProperty)) {
- const enumOptions =
- getWorkflowCodeFieldsEnumSelectOptions(schemaProperty);
-
- if (isNonEmptyArray(enumOptions)) {
- return (
- onInputChange?.(value, currentPath)}
- VariablePicker={VariablePicker}
- options={enumOptions}
- />
- );
- }
- }
-
return (
- onInputChange?.(value, currentPath)}
VariablePicker={VariablePicker}
- multiline={schemaProperty?.multiline === true}
/>
);
})}
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/getWorkflowCodeFieldsLeafKind.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/getWorkflowCodeFieldsLeafKind.test.ts
index 4c68d33d86..b4280e827a 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/getWorkflowCodeFieldsLeafKind.test.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/getWorkflowCodeFieldsLeafKind.test.ts
@@ -33,4 +33,44 @@ describe('getWorkflowCodeFieldsLeafKind', () => {
);
expect(getWorkflowCodeFieldsLeafKind(undefined)).toBe('text');
});
+
+ it('should map record/records types to record kinds', () => {
+ expect(
+ getWorkflowCodeFieldsLeafKind({
+ type: 'record',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ }),
+ ).toBe('record');
+ expect(
+ getWorkflowCodeFieldsLeafKind({
+ type: 'records',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ }),
+ ).toBe('record-array');
+ });
+
+ it('should map the legacy object/array+marker form to record kinds', () => {
+ expect(
+ getWorkflowCodeFieldsLeafKind({
+ type: 'object',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ }),
+ ).toBe('record');
+ expect(
+ getWorkflowCodeFieldsLeafKind({
+ type: 'array',
+ items: {
+ type: 'object',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ }),
+ ).toBe('record-array');
+ expect(getWorkflowCodeFieldsLeafKind({ type: 'object' })).toBe('text');
+ expect(
+ getWorkflowCodeFieldsLeafKind({
+ type: 'array',
+ items: { type: 'object' },
+ }),
+ ).toBe('text');
+ });
});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts
index 233315172d..d560555dbc 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/__tests__/mergeDefaultFunctionInputAndFunctionInput.test.ts
@@ -48,4 +48,31 @@ describe('mergeDefaultFunctionInputAndFunctionInput', () => {
}),
).toEqual({ briefs: '["a", "b"]', b: null });
});
+
+ it('should preserve stored record values for record-typed inputs', () => {
+ const newInput = { company: null, people: [] };
+ const oldInput = {
+ company: '20202020-aaaa-4bbb-8ccc-111111111111',
+ people: ['20202020-aaaa-4bbb-8ccc-222222222222', '{{trigger.record.id}}'],
+ };
+
+ expect(
+ mergeDefaultFunctionInputAndFunctionInput({
+ newInput,
+ oldInput,
+ }),
+ ).toEqual(oldInput);
+ });
+
+ it('should reset stale empty objects to null for record-typed inputs', () => {
+ const newInput = { company: null };
+ const oldInput = { company: {} };
+
+ expect(
+ mergeDefaultFunctionInputAndFunctionInput({
+ newInput,
+ oldInput,
+ }),
+ ).toEqual({ company: null });
+ });
});
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts
index 247455ee2d..873d8aad8b 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/code-action/utils/getWorkflowCodeFieldsLeafKind.ts
@@ -1,9 +1,19 @@
import { isNonEmptyArray } from '@sniptt/guards';
+import {
+ isRecordArraySchema,
+ isRecordObjectSchema,
+} from 'twenty-shared/logic-function';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type InputSchemaProperty } from 'twenty-shared/workflow';
-type WorkflowCodeFieldsLeafKind = 'boolean' | 'enum' | 'number' | 'text';
+type WorkflowCodeFieldsLeafKind =
+ | 'boolean'
+ | 'enum'
+ | 'number'
+ | 'record'
+ | 'record-array'
+ | 'text';
export const getWorkflowCodeFieldsLeafKind = (
property: InputSchemaProperty | undefined,
@@ -12,6 +22,14 @@ export const getWorkflowCodeFieldsLeafKind = (
return 'text';
}
+ if (isRecordObjectSchema(property)) {
+ return 'record';
+ }
+
+ if (isRecordArraySchema(property)) {
+ return 'record-array';
+ }
+
if (
(property.type === 'string' || property.type === FieldMetadataType.TEXT) &&
isNonEmptyArray(property.enum)
diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md
index 436d09ac62..a0651b8d29 100644
--- a/packages/twenty-sdk/README.md
+++ b/packages/twenty-sdk/README.md
@@ -32,6 +32,10 @@ Full documentation is available at **[docs.twenty.com/developers/extend/apps](ht
- [Building Apps](https://docs.twenty.com/developers/extend/apps/building) — entity definitions, API clients, testing, CLI reference
- [Publishing](https://docs.twenty.com/developers/extend/apps/publishing) — deploy, npm publish, marketplace
+Guides in this repository:
+
+- [Logic function inputs](./docs/logic-function-inputs.md) — input schema inference, record-typed inputs, and the id contract
+
## Manual installation
If you are adding `twenty-sdk` to an existing project instead of using `create-twenty-app`:
diff --git a/packages/twenty-sdk/docs/logic-function-inputs.md b/packages/twenty-sdk/docs/logic-function-inputs.md
new file mode 100644
index 0000000000..cb3585ae57
--- /dev/null
+++ b/packages/twenty-sdk/docs/logic-function-inputs.md
@@ -0,0 +1,66 @@
+# Logic function inputs
+
+When a logic function opts into the workflow action or AI tool surface but does
+not declare an explicit `inputSchema`, the SDK infers one from the handler's
+parameter type during the manifest build. The workflow builder uses that schema
+to render an input form, and record-typed inputs render as record pickers.
+
+## How inference works
+
+Inference reads the handler's single `params` object type and maps each property:
+
+- `string` / `number` / `boolean` map to the matching scalar input.
+- String literal unions (`'a' | 'b'`) map to a select input.
+- `T[]` / `Array` map to array inputs.
+- `TwentyRecord<'objectUniversalIdentifier'>` maps to a record input (see below).
+
+Inference runs only when the trigger settings omit `inputSchema`. Providing an
+explicit `inputSchema` disables inference for that surface entirely — this is the
+escape hatch when a handler type cannot be expressed inline.
+
+## Record-typed inputs
+
+To bind an input to a workspace object, type it with `TwentyRecord`, passing the
+object's universal identifier as a string literal:
+
+```ts
+import { defineLogicFunction, type TwentyRecord } from 'twenty-sdk/define';
+
+const handler = async (params: {
+ companyId: TwentyRecord<'20202020-b374-4779-a561-80086cb2e17f'>;
+ postCardIds: TwentyRecord<'54b589ca-eeed-4950-a176-358418b85c05'>[];
+}) => {
+ return {
+ companyId: params.companyId,
+ postCardCount: params.postCardIds.length,
+ };
+};
+```
+
+The universal identifier is the source of truth and is read directly from the
+literal — there is no name matching, so an unrelated type can never be mistaken
+for a record.
+
+- **Standard objects**: get the identifier from `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`
+ (exported from `twenty-sdk/define`), e.g.
+ `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier`.
+- **App objects**: use the `universalIdentifier` you set on the object's
+ `defineObject(...)`.
+
+Only a string-literal argument resolves. `TwentyRecord` with no argument, or with
+a non-literal argument, is treated as an unknown input.
+
+## What the handler receives
+
+`TwentyRecord` is a branded `string`: `companyId` is a record id, and
+`postCardIds` is an array of record ids. This matches what the runtime delivers —
+the workflow action passes the selected record ids (or the value a bound
+`{{variable}}` resolves to) straight to the handler. Handlers must therefore
+accept ids. The People Data Labs functions model this:
+
+```ts
+export type RecordInput = string | { id?: string | null };
+```
+
+and normalize the input with an `extractRecordIds` helper before use. If a
+handler needs full records, it fetches them by id with the Core API client.
diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts
index 5c55554f7a..26332a2579 100644
--- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts
@@ -1659,6 +1659,37 @@ export const EXPECTED_MANIFEST: Manifest = {
},
universalIdentifier: 'a1b2c3d4-1001-4a7b-8c9d-0e1f2a3b4c5d',
},
+ {
+ builtHandlerChecksum: '[checksum]',
+ builtHandlerPath: 'src/logic-functions/enrich-post-cards.function.mjs',
+ description: 'Enrich post cards of a company',
+ handlerName: 'default.config.handler',
+ name: 'enrich-post-cards',
+ sourceHandlerPath: 'src/logic-functions/enrich-post-cards.function.ts',
+ timeoutSeconds: 5,
+ workflowActionTriggerSettings: {
+ label: 'Enrich Post Cards',
+ icon: 'IconMail',
+ inputSchema: [
+ {
+ type: 'object',
+ properties: {
+ companyId: {
+ type: 'record',
+ objectUniversalIdentifier:
+ '20202020-b374-4779-a561-80086cb2e17f',
+ },
+ postCardIds: {
+ type: 'records',
+ objectUniversalIdentifier:
+ '54b589ca-eeed-4950-a176-358418b85c05',
+ },
+ },
+ },
+ ],
+ },
+ universalIdentifier: 'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d',
+ },
{
builtHandlerChecksum: '[checksum]',
builtHandlerPath: 'src/logic-functions/on-post-card-created.function.mjs',
diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts
index ae2d7be194..2f0e7f4823 100644
--- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/entities.tests.ts
@@ -27,6 +27,8 @@ export const defineEntitiesTests = (appPath: string): void => {
'src/components/test.front-component.mjs',
'src/components/test.front-component.mjs.map',
'src/logic-functions',
+ 'src/logic-functions/enrich-post-cards.function.mjs',
+ 'src/logic-functions/enrich-post-cards.function.mjs.map',
'src/logic-functions/greeting.function.mjs',
'src/logic-functions/greeting.function.mjs.map',
'src/logic-functions/lookup-recipient.function.mjs',
diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts
index 3d4b4f6c25..e928b6ce5d 100644
--- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts
@@ -20,7 +20,7 @@ export const defineManifestTests = (appPath: string): void => {
expect(manifest).not.toBeNull();
expect(manifest.objects).toHaveLength(4);
- expect(manifest.logicFunctions).toHaveLength(6);
+ expect(manifest.logicFunctions).toHaveLength(7);
expect(manifest.frontComponents).toHaveLength(4);
expect(manifest.roles).toHaveLength(2);
expect(manifest.fields).toHaveLength(23);
diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-logic-function-input-schema.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-logic-function-input-schema.spec.ts
new file mode 100644
index 0000000000..6fd419b0fa
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-build-logic-function-input-schema.spec.ts
@@ -0,0 +1,41 @@
+import { RICH_APP_PATH } from '@/cli/__tests__/apps/fixture-paths';
+import { buildManifest } from '@/cli/utilities/build/manifest/manifest-build';
+import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
+
+const ENRICH_POST_CARDS_FUNCTION_UNIVERSAL_IDENTIFIER =
+ 'a1b2c3d4-ac10-4a7b-8c9d-0e1f2a3b4c5d';
+const POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER =
+ '54b589ca-eeed-4950-a176-358418b85c05';
+
+describe('buildManifest logic function input schema inference', () => {
+ it('resolves record-typed inputs to standard and app object universal identifiers', async () => {
+ const { manifest, errors } = await buildManifest(RICH_APP_PATH);
+
+ expect(errors).toEqual([]);
+ expect(manifest).not.toBeNull();
+
+ const logicFunction = manifest?.logicFunctions.find(
+ (entry) =>
+ entry.universalIdentifier ===
+ ENRICH_POST_CARDS_FUNCTION_UNIVERSAL_IDENTIFIER,
+ );
+
+ expect(logicFunction).toBeDefined();
+ expect(logicFunction?.workflowActionTriggerSettings?.inputSchema).toEqual([
+ {
+ type: 'object',
+ properties: {
+ companyId: {
+ type: 'record',
+ objectUniversalIdentifier:
+ STANDARD_OBJECTS.company.universalIdentifier,
+ },
+ postCardIds: {
+ type: 'records',
+ objectUniversalIdentifier: POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER,
+ },
+ },
+ },
+ ]);
+ }, 60000);
+});
diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts
index b1a7418e58..b73b157b3d 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts
+++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts
@@ -279,11 +279,6 @@ export const buildManifest = async (
const { handler: _, ...rest } = extract.config;
- const relativeFilePath = relative(appPath, filePath);
-
- // Auto-infer inputSchema for any trigger that opts in but omits one.
- // For the AI tool surface we use the JSON schema directly; for the
- // workflow action surface we convert to Twenty's InputSchema.
const inferredJsonSchema =
(rest.toolTriggerSettings && !rest.toolTriggerSettings.inputSchema) ||
(rest.workflowActionTriggerSettings &&
@@ -319,8 +314,8 @@ export const buildManifest = async (
? { workflowActionTriggerSettings }
: {}),
handlerName: 'default.config.handler',
- sourceHandlerPath: relativeFilePath,
- builtHandlerPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
+ sourceHandlerPath: relativePath,
+ builtHandlerPath: relativePath.replace(/\.tsx?$/, '.mjs'),
builtHandlerChecksum: '[default-checksum]',
};
diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts
index d09db7a5f3..7f6b6d8cc9 100644
--- a/packages/twenty-sdk/src/sdk/define/index.ts
+++ b/packages/twenty-sdk/src/sdk/define/index.ts
@@ -114,6 +114,7 @@ export {
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS as STANDARD_OBJECT,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from '@/sdk/define/objects/standard-object-ids';
+export type { TwentyRecord } from '@/sdk/define/objects/twenty-record.type';
export { definePageLayout } from '@/sdk/define/page-layouts/define-page-layout';
export { definePageLayoutTab } from '@/sdk/define/page-layouts/define-page-layout-tab';
diff --git a/packages/twenty-sdk/src/sdk/define/objects/twenty-record.type.ts b/packages/twenty-sdk/src/sdk/define/objects/twenty-record.type.ts
new file mode 100644
index 0000000000..7a65fdd388
--- /dev/null
+++ b/packages/twenty-sdk/src/sdk/define/objects/twenty-record.type.ts
@@ -0,0 +1,2 @@
+export type TwentyRecord =
+ string & { readonly __object?: TObjectUniversalIdentifier };
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts
index b06fed4bd1..5ebbaeca4f 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts
@@ -1,7 +1,10 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
-import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
+import {
+ buildToolInputJsonSchema,
+ DEFAULT_TOOL_INPUT_SCHEMA,
+} from 'twenty-shared/logic-function';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -46,14 +49,18 @@ export class LogicFunctionToolProvider implements ToolProvider {
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
- const { flatLogicFunctionMaps } =
+ const { flatLogicFunctionMaps, flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: context.workspaceId,
- flatMapsKeys: ['flatLogicFunctionMaps'],
+ flatMapsKeys: ['flatLogicFunctionMaps', 'flatObjectMetadataMaps'],
},
);
+ const resolveObjectLabel = (objectUniversalIdentifier: string) =>
+ flatObjectMetadataMaps.byUniversalIdentifier[objectUniversalIdentifier]
+ ?.labelSingular;
+
const logicFunctionsWithSchema = Object.values(
flatLogicFunctionMaps.byUniversalIdentifier,
).filter(
@@ -83,9 +90,12 @@ export class LogicFunctionToolProvider implements ToolProvider {
if (includeSchemas) {
descriptors.push({
...base,
- inputSchema:
- (logicFunction.toolTriggerSettings?.inputSchema as object) ??
- DEFAULT_TOOL_INPUT_SCHEMA,
+ inputSchema: isDefined(logicFunction.toolTriggerSettings?.inputSchema)
+ ? (buildToolInputJsonSchema(
+ logicFunction.toolTriggerSettings.inputSchema,
+ resolveObjectLabel,
+ ) as object)
+ : DEFAULT_TOOL_INPUT_SCHEMA,
});
} else {
descriptors.push(base);
diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type.ts
index 70444aeb36..84e90dab77 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type.ts
@@ -5,6 +5,8 @@ export type InputSchemaPropertyType =
| 'boolean'
| 'object'
| 'array'
+ | 'record'
+ | 'records'
| 'unknown'
| FieldMetadataType;
@@ -13,6 +15,7 @@ export type InputSchemaProperty = {
enum?: string[];
items?: InputSchemaProperty; // used to describe array type elements
properties?: Properties; // used to describe object type elements
+ objectUniversalIdentifier?: string;
};
type Properties = {
diff --git a/packages/twenty-shared/src/logic-function/__tests__/build-tool-input-json-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/build-tool-input-json-schema.test.ts
new file mode 100644
index 0000000000..da645a5f6a
--- /dev/null
+++ b/packages/twenty-shared/src/logic-function/__tests__/build-tool-input-json-schema.test.ts
@@ -0,0 +1,154 @@
+import { buildToolInputJsonSchema } from '@/logic-function/build-tool-input-json-schema';
+
+describe('buildToolInputJsonSchema', () => {
+ it('should collapse a record-typed object to an id string with a resolved label', () => {
+ const result = buildToolInputJsonSchema(
+ {
+ type: 'object',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ (universalIdentifier) =>
+ universalIdentifier === 'company-universal-identifier'
+ ? 'Company'
+ : undefined,
+ );
+
+ expect(result).toEqual({
+ type: 'string',
+ description: 'Id of the Company record',
+ });
+ });
+
+ it('should fall back to a generic label when none resolves', () => {
+ expect(
+ buildToolInputJsonSchema({
+ type: 'object',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ }),
+ ).toEqual({
+ type: 'string',
+ description: 'Id of the linked record',
+ });
+ });
+
+ it('should collapse arrays of records to arrays of id strings', () => {
+ const result = buildToolInputJsonSchema(
+ {
+ type: 'array',
+ items: {
+ type: 'object',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ },
+ () => 'Person',
+ );
+
+ expect(result).toEqual({
+ type: 'array',
+ items: { type: 'string', description: 'Id of the Person record' },
+ });
+ });
+
+ it('should collapse a record type to an id string', () => {
+ expect(
+ buildToolInputJsonSchema(
+ {
+ type: 'record',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ () => 'Company',
+ ),
+ ).toEqual({ type: 'string', description: 'Id of the Company record' });
+ });
+
+ it('should collapse a records type to an array of id strings', () => {
+ expect(
+ buildToolInputJsonSchema(
+ {
+ type: 'records',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ () => 'Person',
+ ),
+ ).toEqual({
+ type: 'array',
+ items: { type: 'string', description: 'Id of the Person record' },
+ });
+ });
+
+ it('should strip custom keywords recursively while keeping standard ones', () => {
+ const result = buildToolInputJsonSchema(
+ {
+ type: 'object',
+ label: 'Params',
+ properties: {
+ company: {
+ type: 'object',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ description: 'A company',
+ },
+ people: {
+ type: 'array',
+ label: 'People',
+ items: {
+ type: 'object',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ },
+ note: { type: 'string', multiline: true },
+ kind: { type: 'string', enum: ['a', 'b'] },
+ },
+ required: ['company'],
+ },
+ () => 'Company',
+ );
+
+ expect(result).toEqual({
+ type: 'object',
+ properties: {
+ company: { type: 'string', description: 'Id of the Company record' },
+ people: {
+ type: 'array',
+ items: { type: 'string', description: 'Id of the Company record' },
+ },
+ note: { type: 'string' },
+ kind: { type: 'string', enum: ['a', 'b'] },
+ },
+ required: ['company'],
+ });
+ });
+
+ it('should keep boolean additionalProperties and sanitize object ones', () => {
+ expect(
+ buildToolInputJsonSchema({
+ type: 'object',
+ additionalProperties: false,
+ }),
+ ).toEqual({ type: 'object', additionalProperties: false });
+
+ expect(
+ buildToolInputJsonSchema({
+ type: 'object',
+ additionalProperties: { type: 'string', multiline: true },
+ }),
+ ).toEqual({ type: 'object', additionalProperties: { type: 'string' } });
+ });
+
+ it('should leave non-record nodes untouched aside from custom keywords', () => {
+ expect(
+ buildToolInputJsonSchema({
+ type: 'object',
+ properties: {
+ plainObject: { type: 'object' },
+ count: { type: 'number', minimum: 0, maximum: 10 },
+ },
+ }),
+ ).toEqual({
+ type: 'object',
+ properties: {
+ plainObject: { type: 'object' },
+ count: { type: 'number', minimum: 0, maximum: 10 },
+ },
+ });
+ });
+});
diff --git a/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts
index 8a62274886..c356c3b683 100644
--- a/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts
+++ b/packages/twenty-shared/src/logic-function/__tests__/get-function-input-schema.test.ts
@@ -86,6 +86,79 @@ describe('getFunctionInputSchema', () => {
]);
});
+ it('should resolve TwentyRecord markers to record schemas', () => {
+ const fileContent = `
+ export const main = (params: {
+ company: TwentyRecord<'company-universal-identifier'>;
+ companies: TwentyRecord<'company-universal-identifier'>[];
+ otherCompanies: Array>;
+ }): void => {
+ return;
+ };
+ `;
+ const result = getFunctionInputSchema(fileContent);
+
+ expect(result).toEqual([
+ {
+ type: 'object',
+ properties: {
+ company: {
+ type: 'record',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ companies: {
+ type: 'records',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ otherCompanies: {
+ type: 'records',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ },
+ },
+ ]);
+ });
+
+ it('should leave a bare TwentyRecord without a literal argument unresolved', () => {
+ const fileContent = `
+ export const main = (params: { record: TwentyRecord }): void => {
+ return;
+ };
+ `;
+ const result = getFunctionInputSchema(fileContent);
+
+ expect(result).toEqual([
+ {
+ type: 'object',
+ properties: {
+ record: {},
+ },
+ },
+ ]);
+ });
+
+ it('should not resolve plain object type references to records', () => {
+ const fileContent = `
+ export const main = (params: {
+ company: Company;
+ companies: Company[];
+ }): void => {
+ return;
+ };
+ `;
+ const result = getFunctionInputSchema(fileContent);
+
+ expect(result).toEqual([
+ {
+ type: 'object',
+ properties: {
+ company: {},
+ companies: { type: 'array', items: {} },
+ },
+ },
+ ]);
+ });
+
it('should analyze a complex function correctly', () => {
const fileContent = `
function testFunction(
diff --git a/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts b/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts
index 6d7b4a6582..7ce808159e 100644
--- a/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts
+++ b/packages/twenty-shared/src/logic-function/__tests__/get-input-schema-from-source-code.test.ts
@@ -18,6 +18,29 @@ describe('getInputSchemaFromSourceCode', () => {
const result = await getInputSchemaFromSourceCode(fileContent);
expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA);
});
+ it('should fall back to empty when the params type cannot be inferred', async () => {
+ const fileContent =
+ 'function testFunction(params: ImportedAlias) { return }';
+ const result = await getInputSchemaFromSourceCode(fileContent);
+ expect(result).toEqual(DEFAULT_TOOL_INPUT_SCHEMA);
+ });
+ it('should infer record schemas from TwentyRecord markers', async () => {
+ const fileContent = `
+ function testFunction(params: {
+ companies: TwentyRecord<'company-universal-identifier'>[];
+ }) { return }
+ `;
+ const result = await getInputSchemaFromSourceCode(fileContent);
+ expect(result).toEqual({
+ type: 'object',
+ properties: {
+ companies: {
+ type: 'records',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ },
+ });
+ });
it('should return input from source code', async () => {
const fileContent = `
function testFunction(
diff --git a/packages/twenty-shared/src/logic-function/__tests__/is-record-array-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/is-record-array-schema.test.ts
new file mode 100644
index 0000000000..e40babbcb7
--- /dev/null
+++ b/packages/twenty-shared/src/logic-function/__tests__/is-record-array-schema.test.ts
@@ -0,0 +1,39 @@
+import { isRecordArraySchema } from '@/logic-function/is-record-array-schema';
+
+describe('isRecordArraySchema', () => {
+ it('returns true for a records schema', () => {
+ expect(
+ isRecordArraySchema({
+ type: 'records',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false for a records schema without an objectUniversalIdentifier', () => {
+ expect(isRecordArraySchema({ type: 'records' })).toBe(false);
+ });
+
+ it('returns true for the legacy array of record objects', () => {
+ expect(
+ isRecordArraySchema({
+ type: 'array',
+ items: {
+ type: 'object',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false for an array of plain objects', () => {
+ expect(
+ isRecordArraySchema({ type: 'array', items: { type: 'object' } }),
+ ).toBe(false);
+ });
+
+ it('returns false for non-array schemas and nullish input', () => {
+ expect(isRecordArraySchema({ type: 'object' })).toBe(false);
+ expect(isRecordArraySchema(undefined)).toBe(false);
+ });
+});
diff --git a/packages/twenty-shared/src/logic-function/__tests__/is-record-object-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/is-record-object-schema.test.ts
new file mode 100644
index 0000000000..cc10c460ae
--- /dev/null
+++ b/packages/twenty-shared/src/logic-function/__tests__/is-record-object-schema.test.ts
@@ -0,0 +1,43 @@
+import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema';
+
+describe('isRecordObjectSchema', () => {
+ it('returns true for a record schema', () => {
+ expect(
+ isRecordObjectSchema({
+ type: 'record',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ }),
+ ).toBe(true);
+ });
+
+ it('returns true for the legacy object+marker schema', () => {
+ expect(
+ isRecordObjectSchema({
+ type: 'object',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false for a record schema without an objectUniversalIdentifier', () => {
+ expect(isRecordObjectSchema({ type: 'record' })).toBe(false);
+ });
+
+ it('returns false for a plain object schema', () => {
+ expect(isRecordObjectSchema({ type: 'object' })).toBe(false);
+ expect(
+ isRecordObjectSchema({ type: 'object', objectUniversalIdentifier: '' }),
+ ).toBe(false);
+ });
+
+ it('returns false for non-object schemas and nullish input', () => {
+ expect(
+ isRecordObjectSchema({
+ type: 'string',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ }),
+ ).toBe(false);
+ expect(isRecordObjectSchema(undefined)).toBe(false);
+ expect(isRecordObjectSchema(null)).toBe(false);
+ });
+});
diff --git a/packages/twenty-shared/src/logic-function/__tests__/json-schema-to-input-schema.test.ts b/packages/twenty-shared/src/logic-function/__tests__/json-schema-to-input-schema.test.ts
index 5ab321f62c..6c520e121b 100644
--- a/packages/twenty-shared/src/logic-function/__tests__/json-schema-to-input-schema.test.ts
+++ b/packages/twenty-shared/src/logic-function/__tests__/json-schema-to-input-schema.test.ts
@@ -140,6 +140,62 @@ describe('jsonSchemaToInputSchema', () => {
});
});
+ it('preserves objectUniversalIdentifier on object properties and array items', () => {
+ const result = jsonSchemaToInputSchema({
+ type: 'object',
+ properties: {
+ company: {
+ type: 'object',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ people: {
+ type: 'array',
+ items: {
+ type: 'object',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ },
+ },
+ });
+
+ expect(result[0].properties?.company).toEqual({
+ type: 'object',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ });
+ expect(result[0].properties?.people).toEqual({
+ type: 'array',
+ items: {
+ type: 'object',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ });
+ });
+
+ it('passes record/records types through with objectUniversalIdentifier', () => {
+ const result = jsonSchemaToInputSchema({
+ type: 'object',
+ properties: {
+ company: {
+ type: 'record',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ people: {
+ type: 'records',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ },
+ });
+
+ expect(result[0].properties?.company).toEqual({
+ type: 'record',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ });
+ expect(result[0].properties?.people).toEqual({
+ type: 'records',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ });
+ });
+
it('does not set label when empty or omitted', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
diff --git a/packages/twenty-shared/src/logic-function/build-tool-input-json-schema.ts b/packages/twenty-shared/src/logic-function/build-tool-input-json-schema.ts
new file mode 100644
index 0000000000..26a426445a
--- /dev/null
+++ b/packages/twenty-shared/src/logic-function/build-tool-input-json-schema.ts
@@ -0,0 +1,81 @@
+import { isNonEmptyString, isObject } from '@sniptt/guards';
+
+import { type InputJsonSchema } from '@/logic-function/input-json-schema.type';
+import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema';
+import { isDefined } from '@/utils/validation/isDefined';
+
+type ResolveObjectLabel = (
+ objectUniversalIdentifier: string,
+) => string | undefined;
+
+const buildRecordIdDescription = (
+ objectUniversalIdentifier: string | undefined,
+ resolveObjectLabel?: ResolveObjectLabel,
+): string => {
+ const objectLabel = isNonEmptyString(objectUniversalIdentifier)
+ ? resolveObjectLabel?.(objectUniversalIdentifier)
+ : undefined;
+
+ return `Id of the ${isNonEmptyString(objectLabel) ? objectLabel : 'linked'} record`;
+};
+
+export const buildToolInputJsonSchema = (
+ jsonSchema: InputJsonSchema,
+ resolveObjectLabel?: ResolveObjectLabel,
+): InputJsonSchema => {
+ if (isRecordObjectSchema(jsonSchema)) {
+ return {
+ type: 'string',
+ description: buildRecordIdDescription(
+ jsonSchema.objectUniversalIdentifier,
+ resolveObjectLabel,
+ ),
+ };
+ }
+
+ if (jsonSchema.type === 'records') {
+ return {
+ type: 'array',
+ items: {
+ type: 'string',
+ description: buildRecordIdDescription(
+ jsonSchema.objectUniversalIdentifier,
+ resolveObjectLabel,
+ ),
+ },
+ };
+ }
+
+ const {
+ objectUniversalIdentifier: _objectUniversalIdentifier,
+ multiline: _multiline,
+ label: _label,
+ items,
+ properties,
+ additionalProperties,
+ ...standardKeywords
+ } = jsonSchema;
+
+ const toolSchema: InputJsonSchema = { ...standardKeywords };
+
+ if (isDefined(items)) {
+ toolSchema.items = buildToolInputJsonSchema(items, resolveObjectLabel);
+ }
+
+ if (isDefined(properties)) {
+ toolSchema.properties = Object.fromEntries(
+ Object.entries(properties).map(([key, value]) => [
+ key,
+ buildToolInputJsonSchema(value, resolveObjectLabel),
+ ]),
+ );
+ }
+
+ if (isDefined(additionalProperties)) {
+ toolSchema.additionalProperties = isObject(additionalProperties)
+ ? buildToolInputJsonSchema(additionalProperties, resolveObjectLabel)
+ : additionalProperties;
+ }
+
+ return toolSchema;
+};
diff --git a/packages/twenty-shared/src/logic-function/get-function-input-schema.ts b/packages/twenty-shared/src/logic-function/get-function-input-schema.ts
index 4da182aaf8..b4129817c3 100644
--- a/packages/twenty-shared/src/logic-function/get-function-input-schema.ts
+++ b/packages/twenty-shared/src/logic-function/get-function-input-schema.ts
@@ -20,6 +20,32 @@ import {
import { type InputJsonSchema } from '@/logic-function';
import { isDefined } from '@/utils/validation/isDefined';
+const TWENTY_RECORD_TYPE_NAME = 'TwentyRecord';
+
+const getObjectUniversalIdentifierFromTypeArgument = (
+ typeReferenceNode: TypeReferenceNode,
+): string | undefined => {
+ const typeArgument = typeReferenceNode.typeArguments?.[0];
+
+ if (
+ isDefined(typeArgument) &&
+ typeArgument.kind === SyntaxKind.LiteralType &&
+ (typeArgument as LiteralTypeNode).literal.kind === SyntaxKind.StringLiteral
+ ) {
+ return ((typeArgument as LiteralTypeNode).literal as StringLiteral).text;
+ }
+
+ return undefined;
+};
+
+const buildArraySchemaFromItems = (items: InputJsonSchema): InputJsonSchema =>
+ items.type === 'record'
+ ? {
+ type: 'records',
+ objectUniversalIdentifier: items.objectUniversalIdentifier,
+ }
+ : { type: 'array', items };
+
const getTypeString = (typeNode: TypeNode): InputJsonSchema => {
switch (typeNode.kind) {
case SyntaxKind.NumberKeyword:
@@ -29,10 +55,9 @@ const getTypeString = (typeNode: TypeNode): InputJsonSchema => {
case SyntaxKind.BooleanKeyword:
return { type: 'boolean' };
case SyntaxKind.ArrayType:
- return {
- type: 'array',
- items: getTypeString((typeNode as ArrayTypeNode).elementType),
- };
+ return buildArraySchemaFromItems(
+ getTypeString((typeNode as ArrayTypeNode).elementType),
+ );
case SyntaxKind.TypeReference: {
const typeReferenceNode = typeNode as TypeReferenceNode;
const typeName =
@@ -43,10 +68,18 @@ const getTypeString = (typeNode: TypeNode): InputJsonSchema => {
if (typeName === 'Array' || typeName === 'ReadonlyArray') {
const elementType = typeReferenceNode.typeArguments?.[0];
- return {
- type: 'array',
- items: isDefined(elementType) ? getTypeString(elementType) : {},
- };
+ return buildArraySchemaFromItems(
+ isDefined(elementType) ? getTypeString(elementType) : {},
+ );
+ }
+
+ if (typeName === TWENTY_RECORD_TYPE_NAME) {
+ const objectUniversalIdentifier =
+ getObjectUniversalIdentifierFromTypeArgument(typeReferenceNode);
+
+ if (isDefined(objectUniversalIdentifier)) {
+ return { type: 'record', objectUniversalIdentifier };
+ }
}
return {};
diff --git a/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts b/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts
index 19e6a2af9f..af8dcb3aec 100644
--- a/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts
+++ b/packages/twenty-shared/src/logic-function/get-input-schema-from-source-code.ts
@@ -11,7 +11,6 @@ export const getInputSchemaFromSourceCode = async (
await import('./get-function-input-schema');
const inputSchema = getFunctionInputSchema(sourceCode);
- // Logic functions take a single params object
const firstParam = inputSchema[0];
if (firstParam?.type === 'object' && isDefined(firstParam.properties)) {
diff --git a/packages/twenty-shared/src/logic-function/index.ts b/packages/twenty-shared/src/logic-function/index.ts
index 96850578cd..0f2ab6226c 100644
--- a/packages/twenty-shared/src/logic-function/index.ts
+++ b/packages/twenty-shared/src/logic-function/index.ts
@@ -7,6 +7,7 @@
* |___/
*/
+export { buildToolInputJsonSchema } from './build-tool-input-json-schema';
export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/DefaultToolInputSchema';
export { SEED_WORKFLOW_ACTION_TRIGGER_SETTINGS } from './constants/SeedWorkflowActionTriggerSettings';
export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code';
@@ -14,4 +15,6 @@ export { getOutputSchemaFromValue } from './get-output-schema-from-value';
export { getOutputSchemaMismatchIssues } from './get-output-schema-mismatch-issues';
export type { InputJsonSchema } from './input-json-schema.type';
export { inputSchemaToOutputSchema } from './input-schema-to-output-schema';
+export { isRecordArraySchema } from './is-record-array-schema';
+export { isRecordObjectSchema } from './is-record-object-schema';
export { jsonSchemaToInputSchema } from './json-schema-to-input-schema';
diff --git a/packages/twenty-shared/src/logic-function/input-json-schema.type.ts b/packages/twenty-shared/src/logic-function/input-json-schema.type.ts
index 782764eede..c9ad5ce2fd 100644
--- a/packages/twenty-shared/src/logic-function/input-json-schema.type.ts
+++ b/packages/twenty-shared/src/logic-function/input-json-schema.type.ts
@@ -6,7 +6,9 @@ export type InputJsonSchema = {
| 'object'
| 'array'
| 'integer'
- | 'null';
+ | 'null'
+ | 'record'
+ | 'records';
description?: string;
enum?: unknown[];
items?: InputJsonSchema;
@@ -17,4 +19,5 @@ export type InputJsonSchema = {
maximum?: number;
multiline?: boolean;
label?: string;
+ objectUniversalIdentifier?: string;
};
diff --git a/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts b/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts
index f350343016..c36df1ae4e 100644
--- a/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts
+++ b/packages/twenty-shared/src/logic-function/input-schema-to-output-schema.ts
@@ -28,6 +28,14 @@ const convertProperty = (
): Leaf | Node => {
const label = property.label ?? key;
+ if (property.type === 'record') {
+ return { isLeaf: true, type: 'string', label, value: null };
+ }
+
+ if (property.type === 'records') {
+ return { isLeaf: true, type: 'array', label, value: null };
+ }
+
if (property.type === 'object') {
return {
isLeaf: false,
diff --git a/packages/twenty-shared/src/logic-function/is-record-array-schema.ts b/packages/twenty-shared/src/logic-function/is-record-array-schema.ts
new file mode 100644
index 0000000000..dd0d3ca8ff
--- /dev/null
+++ b/packages/twenty-shared/src/logic-function/is-record-array-schema.ts
@@ -0,0 +1,19 @@
+import { isNonEmptyString } from '@sniptt/guards';
+
+import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema';
+
+type RecordArraySchema = {
+ type?: string;
+ objectUniversalIdentifier?: string;
+ items?: {
+ type?: string;
+ objectUniversalIdentifier?: string;
+ } | null;
+};
+
+export const isRecordArraySchema = (
+ schema: RecordArraySchema | null | undefined,
+): boolean =>
+ (schema?.type === 'records' &&
+ isNonEmptyString(schema.objectUniversalIdentifier)) ||
+ (schema?.type === 'array' && isRecordObjectSchema(schema?.items));
diff --git a/packages/twenty-shared/src/logic-function/is-record-object-schema.ts b/packages/twenty-shared/src/logic-function/is-record-object-schema.ts
new file mode 100644
index 0000000000..892e056b12
--- /dev/null
+++ b/packages/twenty-shared/src/logic-function/is-record-object-schema.ts
@@ -0,0 +1,12 @@
+import { isNonEmptyString } from '@sniptt/guards';
+
+type RecordObjectSchema = {
+ type?: string;
+ objectUniversalIdentifier?: string;
+};
+
+export const isRecordObjectSchema = (
+ schema: TSchema | null | undefined,
+): schema is TSchema & { objectUniversalIdentifier: string } =>
+ (schema?.type === 'record' || schema?.type === 'object') &&
+ isNonEmptyString(schema.objectUniversalIdentifier);
diff --git a/packages/twenty-shared/src/logic-function/json-schema-to-input-schema.ts b/packages/twenty-shared/src/logic-function/json-schema-to-input-schema.ts
index 2c0a17633a..e72e6fd56d 100644
--- a/packages/twenty-shared/src/logic-function/json-schema-to-input-schema.ts
+++ b/packages/twenty-shared/src/logic-function/json-schema-to-input-schema.ts
@@ -36,6 +36,12 @@ const convertProperty = (jsonSchema: InputJsonSchema): InputSchemaProperty => {
);
}
break;
+ case 'record':
+ property.type = 'record';
+ break;
+ case 'records':
+ property.type = 'records';
+ break;
case 'null':
default:
property.type = 'unknown';
@@ -55,6 +61,10 @@ const convertProperty = (jsonSchema: InputJsonSchema): InputSchemaProperty => {
property.label = jsonSchema.label;
}
+ if (isNonEmptyString(jsonSchema.objectUniversalIdentifier)) {
+ property.objectUniversalIdentifier = jsonSchema.objectUniversalIdentifier;
+ }
+
return property;
};
diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts
index 745381058c..c905a25379 100644
--- a/packages/twenty-shared/src/workflow/index.ts
+++ b/packages/twenty-shared/src/workflow/index.ts
@@ -94,6 +94,7 @@ export { workflowTriggerSchema } from './schemas/workflow-trigger-schema';
export type { EmailRecipients } from './types/EmailRecipients';
export type { FunctionInput } from './types/FunctionInput';
export type {
+ RecordSchemaType,
InputSchemaPropertyType,
InputSchemaProperty,
InputSchema,
diff --git a/packages/twenty-shared/src/workflow/types/InputSchema.ts b/packages/twenty-shared/src/workflow/types/InputSchema.ts
index 1032899323..89f6dce4a2 100644
--- a/packages/twenty-shared/src/workflow/types/InputSchema.ts
+++ b/packages/twenty-shared/src/workflow/types/InputSchema.ts
@@ -1,7 +1,13 @@
import { type FieldMetadataType } from '@/types';
import { type LeafType, type NodeType } from '@/workflow';
-export type InputSchemaPropertyType = LeafType | NodeType | FieldMetadataType;
+export type RecordSchemaType = 'record' | 'records';
+
+export type InputSchemaPropertyType =
+ | LeafType
+ | NodeType
+ | RecordSchemaType
+ | FieldMetadataType;
export type InputSchemaProperty = {
type: InputSchemaPropertyType;
@@ -10,6 +16,7 @@ export type InputSchemaProperty = {
properties?: Properties;
multiline?: boolean;
label?: string;
+ objectUniversalIdentifier?: string;
};
type Properties = {
diff --git a/packages/twenty-shared/src/workflow/utils/__tests__/getDefaultFunctionInputFromInputSchema.test.ts b/packages/twenty-shared/src/workflow/utils/__tests__/getDefaultFunctionInputFromInputSchema.test.ts
index 51931b74c2..63a5ab56f7 100644
--- a/packages/twenty-shared/src/workflow/utils/__tests__/getDefaultFunctionInputFromInputSchema.test.ts
+++ b/packages/twenty-shared/src/workflow/utils/__tests__/getDefaultFunctionInputFromInputSchema.test.ts
@@ -56,4 +56,56 @@ describe('getDefaultFunctionInputFromInputSchema', () => {
{ briefs: [] },
]);
});
+
+ it('should init record-typed inputs with null and record arrays with empty arrays', () => {
+ const inputSchema = [
+ {
+ type: 'object',
+ properties: {
+ company: {
+ type: 'object',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ people: {
+ type: 'array',
+ items: {
+ type: 'object',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ },
+ plainObject: { type: 'object' },
+ },
+ },
+ ] as InputSchema;
+
+ expect(getFunctionInputFromInputSchema(inputSchema)).toEqual([
+ {
+ company: null,
+ people: [],
+ plainObject: {},
+ },
+ ]);
+ });
+
+ it('should init record/records types with null and empty array', () => {
+ const inputSchema = [
+ {
+ type: 'object',
+ properties: {
+ company: {
+ type: 'record',
+ objectUniversalIdentifier: 'company-universal-identifier',
+ },
+ people: {
+ type: 'records',
+ objectUniversalIdentifier: 'person-universal-identifier',
+ },
+ },
+ },
+ ] as InputSchema;
+
+ expect(getFunctionInputFromInputSchema(inputSchema)).toEqual([
+ { company: null, people: [] },
+ ]);
+ });
});
diff --git a/packages/twenty-shared/src/workflow/utils/getFunctionInputFromInputSchema.ts b/packages/twenty-shared/src/workflow/utils/getFunctionInputFromInputSchema.ts
index c2bdb3555e..018ffd0f6a 100644
--- a/packages/twenty-shared/src/workflow/utils/getFunctionInputFromInputSchema.ts
+++ b/packages/twenty-shared/src/workflow/utils/getFunctionInputFromInputSchema.ts
@@ -1,11 +1,20 @@
import { type InputSchema, type FunctionInput } from '@/workflow';
import { type InputJsonSchema } from '@/logic-function';
+import { isRecordObjectSchema } from '@/logic-function/is-record-object-schema';
import { isDefined } from '@/utils';
export const getFunctionInputFromInputSchema = (
inputSchema: InputSchema | InputJsonSchema[],
): FunctionInput => {
return inputSchema.map((param) => {
+ if (isRecordObjectSchema(param)) {
+ return null;
+ }
+
+ if (param.type === 'records') {
+ return [];
+ }
+
if (
isDefined(param.type) &&
['string', 'number', 'boolean'].includes(param.type)