feat(apps): split AI tool and workflow action triggers in LogicFunction manifest (#20208)

## Summary

Replaces the bolted-on `isTool` + `toolInputSchema` fields on
`LogicFunctionManifest` with two distinct, opt-in triggers that align
with the existing `cron` / `databaseEvent` / `httpRoute` trigger
pattern:

- **`toolTriggerSettings`** — exposes the function as an AI tool (chat /
MCP / function calling). Uses standard JSON Schema (the format LLMs
natively understand).
- **`workflowActionTriggerSettings`** — exposes the function as a step
in the visual workflow builder. Uses Twenty's rich `InputSchema` so the
builder can render proper `FieldMetadataType`-aware editors, variable
pickers, labels, and an optional `outputSchema`.

A function can opt into none, one, or both. Each surface gets the schema
format appropriate for it.

### Why

`isTool: true` previously exposed the function as both an AI tool AND a
workflow node, with the same JSON Schema feeding both — but the workflow
builder really wants Twenty's `InputSchema` (with `CURRENCY`,
`RELATION`, `EMAILS`, etc.) and the AI surface really wants standard
JSON Schema. Today the workflow builder hacks around this by treating
JSON Schema as `InputSchema`, which silently breaks for any
non-primitive field type. Splitting the triggers fixes that and lets
each surface evolve independently.

### Migration

- **Fast** instance command adds the two new nullable columns.
- **Slow** instance command backfills `toolTriggerSettings` +
`workflowActionTriggerSettings` from `isTool=true` rows (preserving
today's both-surfaces behaviour) then drops the legacy columns.

### Stacked

Stacked on top of #20181. Merge that first, then this.

## Test plan

- [ ] CI green (oxlint, typecheck, jest, vitest)
- [ ] Run `--include-slow` upgrade against a workspace with existing
`isTool=true` logic functions; verify both new columns populated and old
columns dropped
- [ ] Verify AI chat sees migrated tool functions (Linear create-issue,
Exa search) and can call them with the JSON Schema
- [ ] Add an AI-tool function from the Settings UI (toggles
`toolTriggerSettings`) and verify it shows up in chat
- [ ] Add a workflow-action function from the Settings UI (toggles
`workflowActionTriggerSettings`) and verify it appears in the workflow
node picker
- [ ] In the workflow builder, edit a `LOGIC_FUNCTION` step and verify
input fields render (no more JSON-Schema-as-InputSchema hack)
- [ ] Try defining a function with no triggers in the SDK and verify
`defineLogicFunction` rejects it

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
Félix Malfait
2026-05-05 14:56:09 +02:00
committed by GitHub
co-authored by Claude Opus 4.6 martmull
parent 36452ecc8b
commit 53fdac1417
66 changed files with 820 additions and 357 deletions
@@ -10,5 +10,13 @@ export default defineLogicFunction({
description: 'Look up a recipient by name to find their details',
timeoutSeconds: 5,
handler,
isTool: true,
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
recipientName: { type: 'string' },
},
required: ['recipientName'],
},
},
});
@@ -111,7 +111,8 @@ export default defineLogicFunction({
description:
'Structured web search powered by Exa. Returns entity-aware results with category filtering (companies, people, research papers, news, and other content types). Prefer this when the query benefits from structured data or a specific category. For general real-time web browsing, prefer the native `web_search` tool when it is available.',
timeoutSeconds: 30,
isTool: true,
toolInputSchema: exaWebSearchInputSchema,
toolTriggerSettings: {
inputSchema: exaWebSearchInputSchema,
},
handler,
});
@@ -10,24 +10,25 @@ export default defineLogicFunction({
'Create a Linear issue on behalf of the connected user. Requires a teamId (call list-linear-teams to discover one) and a title.',
timeoutSeconds: 30,
handler: createLinearIssueHandler,
isTool: true,
toolInputSchema: {
type: 'object',
properties: {
teamId: {
type: 'string',
description:
'The Linear team ID to create the issue in. Use list-linear-teams to discover available teams.',
},
title: {
type: 'string',
description: 'The issue title.',
},
description: {
type: 'string',
description: 'Optional issue description (Markdown supported).',
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
teamId: {
type: 'string',
description:
'The Linear team ID to create the issue in. Use list-linear-teams to discover available teams.',
},
title: {
type: 'string',
description: 'The issue title.',
},
description: {
type: 'string',
description: 'Optional issue description (Markdown supported).',
},
},
required: ['teamId', 'title'],
},
required: ['teamId', 'title'],
},
});
@@ -10,9 +10,10 @@ export default defineLogicFunction({
"Returns the connected user's Linear teams. Useful for picking a teamId to pass to create-linear-issue.",
timeoutSeconds: 15,
handler: listLinearTeamsHandler,
isTool: true,
toolInputSchema: {
type: 'object',
properties: {},
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {},
},
},
});
@@ -332,11 +332,11 @@ type LogicFunction {
timeoutSeconds: Float!
sourceHandlerPath: String!
handlerName: String!
toolInputSchema: JSON
isTool: Boolean!
cronTriggerSettings: JSON
databaseEventTriggerSettings: JSON
httpRouteTriggerSettings: JSON
toolTriggerSettings: JSON
workflowActionTriggerSettings: JSON
applicationId: UUID
universalIdentifier: UUID
createdAt: DateTime!
@@ -3779,12 +3779,12 @@ input CreateLogicFunctionFromSourceInput {
name: String!
description: String
timeoutSeconds: Float
toolInputSchema: JSON
isTool: Boolean
source: JSON
cronTriggerSettings: JSON
databaseEventTriggerSettings: JSON
httpRouteTriggerSettings: JSON
toolTriggerSettings: JSON
workflowActionTriggerSettings: JSON
}
input ExecuteOneLogicFunctionInput {
@@ -3808,13 +3808,13 @@ input UpdateLogicFunctionFromSourceInputUpdates {
description: String
timeoutSeconds: Float
sourceHandlerCode: String
toolInputSchema: JSON
handlerName: String
sourceHandlerPath: String
isTool: Boolean
cronTriggerSettings: JSON
databaseEventTriggerSettings: JSON
httpRouteTriggerSettings: JSON
toolTriggerSettings: JSON
workflowActionTriggerSettings: JSON
}
input CreateCommandMenuItemInput {
@@ -286,11 +286,11 @@ export interface LogicFunction {
timeoutSeconds: Scalars['Float']
sourceHandlerPath: Scalars['String']
handlerName: Scalars['String']
toolInputSchema?: Scalars['JSON']
isTool: Scalars['Boolean']
cronTriggerSettings?: Scalars['JSON']
databaseEventTriggerSettings?: Scalars['JSON']
httpRouteTriggerSettings?: Scalars['JSON']
toolTriggerSettings?: Scalars['JSON']
workflowActionTriggerSettings?: Scalars['JSON']
applicationId?: Scalars['UUID']
universalIdentifier?: Scalars['UUID']
createdAt: Scalars['DateTime']
@@ -3153,11 +3153,11 @@ export interface LogicFunctionGenqlSelection{
timeoutSeconds?: boolean | number
sourceHandlerPath?: boolean | number
handlerName?: boolean | number
toolInputSchema?: boolean | number
isTool?: boolean | number
cronTriggerSettings?: boolean | number
databaseEventTriggerSettings?: boolean | number
httpRouteTriggerSettings?: boolean | number
toolTriggerSettings?: boolean | number
workflowActionTriggerSettings?: boolean | number
applicationId?: boolean | number
universalIdentifier?: boolean | number
createdAt?: boolean | number
@@ -6076,7 +6076,7 @@ export interface CreatePageLayoutWidgetInput {pageLayoutTabId: Scalars['UUID'],t
export interface UpdatePageLayoutWidgetInput {pageLayoutTabId?: (Scalars['UUID'] | null),title?: (Scalars['String'] | null),type?: (WidgetType | null),objectMetadataId?: (Scalars['UUID'] | null),gridPosition?: (GridPositionInput | null),position?: (Scalars['JSON'] | null),configuration?: (Scalars['JSON'] | null),conditionalDisplay?: (Scalars['JSON'] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null)}
export interface CreateLogicFunctionFromSourceInput {id?: (Scalars['UUID'] | null),universalIdentifier?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),toolInputSchema?: (Scalars['JSON'] | null),isTool?: (Scalars['Boolean'] | null),source?: (Scalars['JSON'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null)}
export interface CreateLogicFunctionFromSourceInput {id?: (Scalars['UUID'] | null),universalIdentifier?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),source?: (Scalars['JSON'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null),toolTriggerSettings?: (Scalars['JSON'] | null),workflowActionTriggerSettings?: (Scalars['JSON'] | null)}
export interface ExecuteOneLogicFunctionInput {
/** Id of the logic function to execute */
@@ -6090,7 +6090,7 @@ id: Scalars['UUID'],
/** The logic function updates */
update: UpdateLogicFunctionFromSourceInputUpdates}
export interface UpdateLogicFunctionFromSourceInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),sourceHandlerCode?: (Scalars['String'] | null),toolInputSchema?: (Scalars['JSON'] | null),handlerName?: (Scalars['String'] | null),sourceHandlerPath?: (Scalars['String'] | null),isTool?: (Scalars['Boolean'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null)}
export interface UpdateLogicFunctionFromSourceInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),sourceHandlerCode?: (Scalars['String'] | null),handlerName?: (Scalars['String'] | null),sourceHandlerPath?: (Scalars['String'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null),toolTriggerSettings?: (Scalars['JSON'] | null),workflowActionTriggerSettings?: (Scalars['JSON'] | null)}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),payload?: (Scalars['JSON'] | null),pageLayoutId?: (Scalars['UUID'] | null)}
@@ -730,12 +730,6 @@ export default {
"handlerName": [
1
],
"toolInputSchema": [
15
],
"isTool": [
6
],
"cronTriggerSettings": [
15
],
@@ -745,6 +739,12 @@ export default {
"httpRouteTriggerSettings": [
15
],
"toolTriggerSettings": [
15
],
"workflowActionTriggerSettings": [
15
],
"applicationId": [
3
],
@@ -9650,12 +9650,6 @@ export default {
"timeoutSeconds": [
11
],
"toolInputSchema": [
15
],
"isTool": [
6
],
"source": [
15
],
@@ -9668,6 +9662,12 @@ export default {
"httpRouteTriggerSettings": [
15
],
"toolTriggerSettings": [
15
],
"workflowActionTriggerSettings": [
15
],
"__typename": [
1
]
@@ -9707,18 +9707,12 @@ export default {
"sourceHandlerCode": [
1
],
"toolInputSchema": [
15
],
"handlerName": [
1
],
"sourceHandlerPath": [
1
],
"isTool": [
6
],
"cronTriggerSettings": [
15
],
@@ -9728,6 +9722,12 @@ export default {
"httpRouteTriggerSettings": [
15
],
"toolTriggerSettings": [
15
],
"workflowActionTriggerSettings": [
15
],
"__typename": [
1
]
@@ -142,11 +142,14 @@ const handler = async (event: RoutePayload) => {
Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`).
</Note>
#### Exposing a function as a tool
#### Exposing a function as an AI tool or workflow action
Logic functions can be exposed as **tools** for AI agents and workflows. When marked as a tool, a function becomes discoverable by Twenty's AI features and can be used in workflow automations.
Logic functions can be exposed on two surfaces, each with its own trigger:
To mark a logic function as a tool, set `isTool: true`:
- **`toolTriggerSettings`** — makes the function discoverable by Twenty's AI features (chat, MCP, function calling). Uses standard JSON Schema, the format LLMs natively understand.
- **`workflowActionTriggerSettings`** — makes the function appear as a step in the visual workflow builder. Uses Twenty's rich `InputSchema` so the builder can render proper field editors, variable pickers, and labels.
A function can opt into one, the other, or both. They sit alongside `cronTriggerSettings`, `databaseEventTriggerSettings`, and `httpRouteTriggerSettings` — same pattern, same shape.
```ts src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
@@ -176,31 +179,33 @@ export default defineLogicFunction({
description: 'Enrich a company record with external data',
timeoutSeconds: 10,
handler,
isTool: true,
toolTriggerSettings: {},
});
```
Key points:
- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events at the same time.
- **`toolInputSchema`** (optional): A JSON Schema object describing the parameters your function accepts. The schema is computed automatically from source code static analysis, but you can set it explicitly:
- A function can mix surfaces — declare both `toolTriggerSettings` and `workflowActionTriggerSettings` to expose it in chat AND in the workflow builder.
- `toolTriggerSettings.inputSchema` and `workflowActionTriggerSettings.inputSchema` are both optional. When omitted, the manifest builder infers them from the handler source code (JSON Schema for the AI tool, Twenty's `InputSchema` for the workflow action). Provide one explicitly when you want richer typing — for example, with `FieldMetadataType`-aware fields like `CURRENCY` or `RELATION` for the workflow builder, or with `description` fields the AI agent can read:
```ts
export default defineLogicFunction({
...,
toolInputSchema: {
type: 'object',
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
companyName: {
type: 'string',
description: 'The name of the company to enrich',
},
domain: {
type: 'string',
description: 'The company website domain (optional)',
},
},
required: ['companyName'],
},
required: ['companyName'],
},
});
```
@@ -239,7 +244,7 @@ yarn twenty exec --postInstall
```
Key points:
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `toolTriggerSettings`, `workflowActionTriggerSettings`).
- The handler receives an `InstallPayload` with `{ previousVersion?: string; newVersion: string }` — `newVersion` is the version being installed, and `previousVersion` is the version that was previously installed (or `undefined` on a fresh install). Use these values to distinguish fresh installs from upgrades and to run version-specific migration logic.
- **When the hook runs**: on fresh installs only, by default. Pass `shouldRunOnVersionUpgrade: true` if you also want it to run when the app is upgraded from a previous version. When omitted, the flag defaults to `false` and upgrades skip the hook.
- **Execution model — async by default, sync opt-in**: the `shouldRunSynchronously` flag controls *how* post-install is executed.
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@ import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAto
import { useSetAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useSetAtomFamilyState';
import { logicFunctionTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/code-action/states/logicFunctionTestDataFamilyState';
import { useEffect } from 'react';
import { type InputJsonSchema } from 'twenty-shared/logic-function';
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
import { isDefined } from 'twenty-shared/utils';
import { getFunctionInputFromInputSchema } from 'twenty-shared/workflow';
import { useLogicFunctionForm } from '@/logic-functions/hooks/useLogicFunctionForm';
@@ -14,7 +14,12 @@ export const LogicFunctionTestInputInitEffect = ({
}) => {
const { logicFunction } = useLogicFunctionForm({ logicFunctionId });
const toolInputSchema = logicFunction?.toolInputSchema;
// Prefer the workflow action schema (already in Twenty's InputSchema form)
// and fall back to converting the AI tool's JSON Schema when only that
// surface is configured.
const workflowInputSchema =
logicFunction?.workflowActionTriggerSettings?.inputSchema;
const toolJsonSchema = logicFunction?.toolTriggerSettings?.inputSchema;
const logicFunctionTestData = useAtomFamilyStateValue(
logicFunctionTestDataFamilyState,
@@ -31,15 +36,18 @@ export const LogicFunctionTestInputInitEffect = ({
return;
}
if (!isDefined(toolInputSchema)) {
let inputSchema = null;
if (isDefined(workflowInputSchema)) {
inputSchema = workflowInputSchema;
} else if (isDefined(toolJsonSchema)) {
inputSchema = jsonSchemaToInputSchema(toolJsonSchema);
}
if (!isDefined(inputSchema)) {
return;
}
const schemaArray: InputJsonSchema[] = Array.isArray(toolInputSchema)
? toolInputSchema
: [toolInputSchema];
const defaultInput = getFunctionInputFromInputSchema(schemaArray)[0];
const defaultInput = getFunctionInputFromInputSchema(inputSchema)[0];
if (!isDefined(defaultInput)) {
return;
@@ -51,7 +59,8 @@ export const LogicFunctionTestInputInitEffect = ({
shouldInitInput: false,
}));
}, [
toolInputSchema,
workflowInputSchema,
toolJsonSchema,
logicFunctionTestData.shouldInitInput,
setLogicFunctionTestData,
]);
@@ -9,11 +9,11 @@ export const LOGIC_FUNCTION_FRAGMENT = gql`
timeoutSeconds
sourceHandlerPath
handlerName
toolInputSchema
isTool
cronTriggerSettings
databaseEventTriggerSettings
httpRouteTriggerSettings
toolTriggerSettings
workflowActionTriggerSettings
applicationId
universalIdentifier
createdAt
@@ -40,15 +40,12 @@ describe('useLogicFunctionUpdateFormState', () => {
name: 'name',
description: '',
sourceHandlerCode: '',
isTool: false,
timeoutSeconds: 300,
toolInputSchema: {
properties: {},
type: 'object',
},
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
toolTriggerSettings: null,
workflowActionTriggerSettings: null,
});
});
});
@@ -5,6 +5,7 @@ import {
import { usePersistLogicFunction } from '@/logic-functions/hooks/usePersistLogicFunction';
import {
getInputSchemaFromSourceCode,
jsonSchemaToInputSchema,
type InputJsonSchema,
} from 'twenty-shared/logic-function';
import { useDebouncedCallback } from 'use-debounce';
@@ -33,19 +34,32 @@ export const useLogicFunctionForm = ({
value: LogicFunctionFormValues[TKey],
): Promise<InputJsonSchema | undefined> => {
if (key === 'sourceHandlerCode') {
const toolInputSchema = await getInputSchemaFromSourceCode(
const inferredJsonSchema = await getInputSchemaFromSourceCode(
value as LogicFunctionFormValues['sourceHandlerCode'],
);
setFormValues((prevState: LogicFunctionFormValues) => ({
...prevState,
sourceHandlerCode: value as string,
toolInputSchema,
// Re-infer schemas for any active surface so they stay in sync
// with the source code.
toolTriggerSettings: prevState.toolTriggerSettings
? {
...prevState.toolTriggerSettings,
inputSchema: inferredJsonSchema,
}
: null,
workflowActionTriggerSettings: prevState.workflowActionTriggerSettings
? {
...prevState.workflowActionTriggerSettings,
inputSchema: jsonSchemaToInputSchema(inferredJsonSchema),
}
: null,
}));
await handleSave();
return toolInputSchema;
return inferredJsonSchema;
}
setFormValues((prevState: LogicFunctionFormValues) => ({
@@ -5,21 +5,22 @@ import {
type CronTriggerSettings,
type DatabaseEventTriggerSettings,
type HttpRouteTriggerSettings,
type ToolTriggerSettings,
type WorkflowActionTriggerSettings,
} from 'twenty-shared/application';
import { type LogicFunction } from '~/generated-metadata/graphql';
import { useGetLogicFunctionSourceCode } from '@/logic-functions/hooks/useGetLogicFunctionSourceCode';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
export type LogicFunctionFormValues = {
name: string;
description: string;
isTool: boolean;
timeoutSeconds: number;
sourceHandlerCode: string;
toolInputSchema?: object;
cronTriggerSettings: CronTriggerSettings | null;
databaseEventTriggerSettings: DatabaseEventTriggerSettings | null;
httpRouteTriggerSettings: HttpRouteTriggerSettings | null;
toolTriggerSettings: ToolTriggerSettings | null;
workflowActionTriggerSettings: WorkflowActionTriggerSettings | null;
};
type SetLogicFunctionFormValues = Dispatch<
@@ -39,13 +40,13 @@ export const useLogicFunctionUpdateFormState = ({
const [formValues, setFormValues] = useState<LogicFunctionFormValues>({
name: '',
description: '',
isTool: false,
sourceHandlerCode: '',
timeoutSeconds: 300,
toolInputSchema: DEFAULT_TOOL_INPUT_SCHEMA,
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
toolTriggerSettings: null,
workflowActionTriggerSettings: null,
});
const { sourceHandlerCode, loading: logicFunctionSourceCodeLoading } =
@@ -64,15 +65,15 @@ export const useLogicFunctionUpdateFormState = ({
...prevState,
name: logicFunction.name || '',
description: logicFunction.description || '',
isTool: logicFunction.isTool ?? false,
timeoutSeconds: logicFunction.timeoutSeconds ?? 300,
toolInputSchema:
logicFunction.toolInputSchema || DEFAULT_TOOL_INPUT_SCHEMA,
cronTriggerSettings: logicFunction.cronTriggerSettings ?? null,
databaseEventTriggerSettings:
logicFunction.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings:
logicFunction.httpRouteTriggerSettings ?? null,
toolTriggerSettings: logicFunction.toolTriggerSettings ?? null,
workflowActionTriggerSettings:
logicFunction.workflowActionTriggerSettings ?? null,
}));
}
}, [logicFunction]);
@@ -23,8 +23,20 @@ describe('getLogicFunctionTriggerLabel', () => {
expect(getLogicFunctionTriggerLabel({}, {})).toBe('');
});
it('returns AI tool when isTool is set', () => {
expect(getLogicFunctionTriggerLabel({ isTool: true })).toBe('AI tool');
it('returns AI tool when toolTriggerSettings is set', () => {
expect(
getLogicFunctionTriggerLabel({
toolTriggerSettings: { inputSchema: { type: 'object' } },
}),
).toBe('AI tool');
});
it('returns Workflow action when workflowActionTriggerSettings is set', () => {
expect(
getLogicFunctionTriggerLabel({
workflowActionTriggerSettings: { inputSchema: [] },
}),
).toBe('Workflow action');
});
it('returns Cron when cron settings are present', () => {
@@ -3,10 +3,11 @@ import { isDefined } from 'twenty-shared/utils';
type LogicFunctionLike = {
universalIdentifier?: string | null;
isTool?: boolean;
cronTriggerSettings?: unknown;
httpRouteTriggerSettings?: unknown;
databaseEventTriggerSettings?: { eventName?: string } | null;
toolTriggerSettings?: unknown;
workflowActionTriggerSettings?: unknown;
};
export const getLogicFunctionTriggerLabel = (
@@ -28,7 +29,8 @@ export const getLogicFunctionTriggerLabel = (
) {
return t`Pre-install`;
}
if (lf.isTool) return t`AI tool`;
if (isDefined(lf.toolTriggerSettings)) return t`AI tool`;
if (isDefined(lf.workflowActionTriggerSettings)) return t`Workflow action`;
if (lf.cronTriggerSettings) return t`Cron`;
if (lf.httpRouteTriggerSettings) return t`HTTP`;
if (lf.databaseEventTriggerSettings) {
@@ -75,8 +75,7 @@ export const SettingsLogicFunctionTestTab = ({
httpRouteTriggerSettings,
cronTriggerSettings,
databaseEventTriggerSettings,
toolInputSchema,
isTool,
toolTriggerSettings,
} = formValues;
const triggerButtons: TriggerButton[] = [];
@@ -93,7 +92,7 @@ export const SettingsLogicFunctionTestTab = ({
Icon: IconDatabase,
});
}
if (isTool) {
if (isDefined(toolTriggerSettings)) {
triggerButtons.push({ kind: 'tool', label: t`AI tool`, Icon: IconTool });
}
@@ -119,7 +118,7 @@ export const SettingsLogicFunctionTestTab = ({
? buildDatabaseEventPayload(databaseEventTriggerSettings)
: {};
case 'tool':
return buildToolPayloadFromSchema(toolInputSchema);
return buildToolPayloadFromSchema(toolTriggerSettings?.inputSchema);
}
})();
updateLogicFunctionInput(payload);
@@ -3,6 +3,7 @@ import { SettingsLogicFunctionCronTriggerSection } from '@/settings/logic-functi
import { SettingsLogicFunctionDatabaseEventTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionDatabaseEventTriggerSection';
import { SettingsLogicFunctionHttpTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionHttpTriggerSection';
import { SettingsLogicFunctionToolTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionToolTriggerSection';
import { SettingsLogicFunctionWorkflowActionTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionWorkflowActionTriggerSection';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -42,7 +43,8 @@ export const SettingsLogicFunctionTriggersTab = ({
isDefined(formValues.httpRouteTriggerSettings) ||
isDefined(formValues.cronTriggerSettings) ||
isDefined(formValues.databaseEventTriggerSettings) ||
formValues.isTool;
isDefined(formValues.toolTriggerSettings) ||
isDefined(formValues.workflowActionTriggerSettings);
if (readonly && !hasAnyTrigger) {
return isDefined(applicationName) ? (
@@ -79,9 +81,13 @@ export const SettingsLogicFunctionTriggersTab = ({
readonly={readonly}
/>
<SettingsLogicFunctionToolTriggerSection
isTool={formValues.isTool}
toolInputSchema={formValues.toolInputSchema}
onChange={onChange('isTool')}
value={formValues.toolTriggerSettings}
onChange={onChange('toolTriggerSettings')}
readonly={readonly}
/>
<SettingsLogicFunctionWorkflowActionTriggerSection
value={formValues.workflowActionTriggerSettings}
onChange={onChange('workflowActionTriggerSettings')}
readonly={readonly}
/>
{!readonly && !hasAnyTrigger && (
@@ -1,29 +1,33 @@
import { SettingsLogicFunctionTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection';
import { useLingui } from '@lingui/react/macro';
import { type ToolTriggerSettings } from 'twenty-shared/application';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'twenty-shared/logic-function';
import { isDefined } from 'twenty-shared/utils';
import { SettingsToolParameterTable } from '~/pages/settings/ai/components/SettingsToolParameterTable';
const DEFAULT_TOOL_TRIGGER_SETTINGS: ToolTriggerSettings = {
inputSchema: DEFAULT_TOOL_INPUT_SCHEMA,
};
type ToolInputSchema = {
properties?: Record<string, unknown>;
required?: string[];
};
type SettingsLogicFunctionToolTriggerSectionProps = {
isTool: boolean;
toolInputSchema?: object;
onChange: (value: boolean) => void;
value: ToolTriggerSettings | null;
onChange: (value: ToolTriggerSettings | null) => void;
readonly: boolean;
};
export const SettingsLogicFunctionToolTriggerSection = ({
isTool,
toolInputSchema,
value,
onChange,
readonly,
}: SettingsLogicFunctionToolTriggerSectionProps) => {
const { t } = useLingui();
const schema = (toolInputSchema as ToolInputSchema | undefined) ?? {};
const schema = (value?.inputSchema as ToolInputSchema | undefined) ?? {};
const schemaProperties = isDefined(schema.properties)
? (schema.properties as Record<
string,
@@ -34,9 +38,11 @@ export const SettingsLogicFunctionToolTriggerSection = ({
return (
<SettingsLogicFunctionTriggerSection
title={t`AI tool`}
description={t`Triggers the function when called by an AI agent or workflow`}
enabled={isTool}
onEnabledChange={onChange}
description={t`Exposes the function as a tool that AI agents can call`}
enabled={isDefined(value)}
onEnabledChange={(checked) =>
onChange(checked ? DEFAULT_TOOL_TRIGGER_SETTINGS : null)
}
readonly={readonly}
>
<SettingsToolParameterTable
@@ -0,0 +1,35 @@
import { SettingsLogicFunctionTriggerSection } from '@/settings/logic-functions/components/triggers/SettingsLogicFunctionTriggerSection';
import { useLingui } from '@lingui/react/macro';
import { type WorkflowActionTriggerSettings } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
const DEFAULT_WORKFLOW_ACTION_TRIGGER_SETTINGS: WorkflowActionTriggerSettings =
{
inputSchema: [],
};
type SettingsLogicFunctionWorkflowActionTriggerSectionProps = {
value: WorkflowActionTriggerSettings | null;
onChange: (value: WorkflowActionTriggerSettings | null) => void;
readonly: boolean;
};
export const SettingsLogicFunctionWorkflowActionTriggerSection = ({
value,
onChange,
readonly,
}: SettingsLogicFunctionWorkflowActionTriggerSectionProps) => {
const { t } = useLingui();
return (
<SettingsLogicFunctionTriggerSection
title={t`Workflow action`}
description={t`Exposes the function as a step in the workflow builder`}
enabled={isDefined(value)}
onEnabledChange={(checked) =>
onChange(checked ? DEFAULT_WORKFLOW_ACTION_TRIGGER_SETTINGS : null)
}
readonly={readonly}
/>
);
};
@@ -11,6 +11,7 @@ import { HUMAN_INPUT_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/
import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions';
import { getActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconFunction } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
@@ -28,7 +29,9 @@ export const SidePanelWorkflowSelectAction = ({
const logicFunctions = useAtomStateValue(logicFunctionsSelector);
const toolFunctions = logicFunctions.filter((fn) => fn.isTool === true);
const toolFunctions = logicFunctions.filter((fn) =>
isDefined(fn.workflowActionTriggerSettings),
);
const handleActionClick = (actionType: WorkflowActionType) => {
onActionSelected({ type: actionType });
@@ -38,6 +38,7 @@ import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import {
getOutputSchemaFromValue,
jsonSchemaToInputSchema,
type InputJsonSchema,
} from 'twenty-shared/logic-function';
import { IconCode, IconPlayerPlay } from 'twenty-ui/display';
@@ -137,7 +138,7 @@ export const WorkflowEditActionCode = ({
);
const handleUpdateFunctionInputSchema = useDebouncedCallback(
async (sourceCode: string, toolInputSchema: InputJsonSchema) => {
async (sourceCode: string, inferredJsonSchema: InputJsonSchema) => {
if (actionOptions.readonly === true) {
return;
}
@@ -146,11 +147,9 @@ export const WorkflowEditActionCode = ({
return;
}
const schemaArray = Array.isArray(toolInputSchema)
? toolInputSchema
: [toolInputSchema];
const inputSchema = jsonSchemaToInputSchema(inferredJsonSchema);
const newFunctionInput = getFunctionInputFromInputSchema(schemaArray)[0];
const newFunctionInput = getFunctionInputFromInputSchema(inputSchema)[0];
const newMergedInput = mergeDefaultFunctionInputAndFunctionInput({
newInput: newFunctionInput,
@@ -260,12 +259,12 @@ export const WorkflowEditActionCode = ({
return;
}
const toolInputSchema = await onChange('sourceHandlerCode')(newCode);
const inferredJsonSchema = await onChange('sourceHandlerCode')(newCode);
await getUpdatableWorkflowVersion();
if (isDefined(toolInputSchema)) {
await handleUpdateFunctionInputSchema(newCode, toolInputSchema);
if (isDefined(inferredJsonSchema)) {
await handleUpdateFunctionInputSchema(newCode, inferredJsonSchema);
}
};
@@ -81,17 +81,14 @@ export const WorkflowEditActionLogicFunction = ({
);
const functionInput = useMemo(() => {
const toolInputSchema = logicFunction?.toolInputSchema;
const inputSchema =
logicFunction?.workflowActionTriggerSettings?.inputSchema;
if (!isDefined(toolInputSchema)) {
if (!isDefined(inputSchema)) {
return action.settings.input.logicFunctionInput ?? {};
}
const schemaArray = Array.isArray(toolInputSchema)
? toolInputSchema
: [toolInputSchema];
const defaultInput = getFunctionInputFromInputSchema(schemaArray)[0];
const defaultInput = getFunctionInputFromInputSchema(inputSchema)[0];
if (!isObject(defaultInput)) {
return action.settings.input.logicFunctionInput ?? {};
@@ -102,7 +99,7 @@ export const WorkflowEditActionLogicFunction = ({
oldInput: action.settings.input.logicFunctionInput ?? {},
});
}, [
logicFunction?.toolInputSchema,
logicFunction?.workflowActionTriggerSettings?.inputSchema,
action.settings.input.logicFunctionInput,
]);
@@ -46,7 +46,9 @@ export const SettingsAI = () => {
const result = await createLogicFunction({
input: {
name: 'new-tool',
isTool: true,
toolTriggerSettings: {
inputSchema: { type: 'object', properties: {} },
},
},
});
@@ -92,7 +92,7 @@ export const SettingsToolDetail = () => {
: systemTool?.description;
const inputSchema = isCustomTool
? logicFunction?.toolInputSchema
? logicFunction?.toolTriggerSettings?.inputSchema
: schemaData?.getToolInputSchema;
const functionLink = isCustomTool
@@ -139,7 +139,7 @@ export const SettingsToolsTable = () => {
const allTools: ToolItem[] = useMemo(
() => [
...logicFunctions
.filter((fn) => fn.isTool === true)
.filter((fn) => isDefined(fn.toolTriggerSettings))
.map((fn) => ({
identifier: fn.id,
name: fn.name,
@@ -0,0 +1,81 @@
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { SettingsRadioCardContainer } from '@/settings/components/SettingsRadioCardContainer';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { H1Title, H1TitleFontColor } from 'twenty-ui/display';
import { SectionAlignment, SectionFontColor } from 'twenty-ui/layout';
import {
StyledAppModal,
StyledAppModalButton,
StyledAppModalSection,
StyledAppModalTitle,
} from '~/pages/settings/applications/components/SettingsAppModalLayout';
type SettingsApplicationConnectScopePickerModalProps = {
modalInstanceId: string;
providerDisplayName: string;
onConfirm: (scope: 'user' | 'workspace') => void;
};
export const SettingsApplicationConnectScopePickerModal = ({
modalInstanceId,
providerDisplayName,
onConfirm,
}: SettingsApplicationConnectScopePickerModalProps) => {
const { t } = useLingui();
const { closeModal } = useModal();
const [scope, setScope] = useState<'user' | 'workspace'>('user');
const options = [
{
value: 'user',
title: t`Just for me`,
description: t`Only you can use this credential.`,
},
{
value: 'workspace',
title: t`Workspace shared`,
description: t`Anyone in this workspace can use this credential.`,
},
];
return (
<StyledAppModal modalId={modalInstanceId} isClosable padding="large">
<StyledAppModalTitle>
<H1Title
title={t`Connect ${providerDisplayName}`}
fontColor={H1TitleFontColor.Primary}
/>
</StyledAppModalTitle>
<StyledAppModalSection
alignment={SectionAlignment.Center}
fontColor={SectionFontColor.Primary}
>
<SettingsRadioCardContainer
value={scope}
options={options}
onChange={(value) => setScope(value as 'user' | 'workspace')}
/>
</StyledAppModalSection>
<StyledAppModalButton
onClick={() => closeModal(modalInstanceId)}
variant="secondary"
title={t`Cancel`}
fullWidth
justify="center"
/>
<StyledAppModalButton
onClick={() => {
closeModal(modalInstanceId);
onConfirm(scope);
}}
variant="secondary"
accent="blue"
title={t`Continue`}
fullWidth
justify="center"
/>
</StyledAppModal>
);
};
@@ -295,7 +295,6 @@ export const EXPECTED_MANIFEST: Manifest = {
sourceHandlerPath: 'my.function.ts',
builtHandlerPath: 'my.function.mjs',
builtHandlerChecksum: '[checksum]',
toolInputSchema: { type: 'object', properties: {} },
},
],
frontComponents: [
@@ -1597,7 +1597,6 @@ export const EXPECTED_MANIFEST: Manifest = {
handlerName: 'default.config.handler',
name: 'root-function',
sourceHandlerPath: 'src/root.function.ts',
toolInputSchema: { type: 'object', properties: {} },
timeoutSeconds: 5,
httpRouteTriggerSettings: {
httpMethod: 'GET',
@@ -1612,7 +1611,6 @@ export const EXPECTED_MANIFEST: Manifest = {
handlerName: 'default.config.handler',
name: 'greeting-function',
sourceHandlerPath: 'src/logic-functions/greeting.function.ts',
toolInputSchema: { type: 'object', properties: {} },
timeoutSeconds: 5,
httpRouteTriggerSettings: {
httpMethod: 'GET',
@@ -1626,16 +1624,18 @@ export const EXPECTED_MANIFEST: Manifest = {
builtHandlerPath: 'src/logic-functions/lookup-recipient.function.mjs',
description: 'Look up a recipient by name to find their details',
handlerName: 'default.config.handler',
isTool: true,
name: 'lookup-recipient',
sourceHandlerPath: 'src/logic-functions/lookup-recipient.function.ts',
timeoutSeconds: 5,
toolInputSchema: {
type: 'object',
properties: {
recipientName: {
type: 'string',
toolTriggerSettings: {
inputSchema: {
type: 'object',
properties: {
recipientName: {
type: 'string',
},
},
required: ['recipientName'],
},
},
universalIdentifier: 'a1b2c3d4-1001-4a7b-8c9d-0e1f2a3b4c5d',
@@ -1651,7 +1651,6 @@ export const EXPECTED_MANIFEST: Manifest = {
name: 'on-post-card-created',
sourceHandlerPath: 'src/logic-functions/on-post-card-created.function.ts',
timeoutSeconds: 5,
toolInputSchema: { type: 'object', properties: {} },
universalIdentifier: 'a1b2c3d4-db01-4a7b-8c9d-0e1f2a3b4c5d',
},
{
@@ -1660,7 +1659,6 @@ export const EXPECTED_MANIFEST: Manifest = {
handlerName: 'default.config.handler',
name: 'test-function-2',
sourceHandlerPath: 'src/logic-functions/test-function-2.function.ts',
toolInputSchema: { type: 'object', properties: {} },
timeoutSeconds: 2,
cronTriggerSettings: {
pattern: '0 0 1 1 *',
@@ -1673,7 +1671,6 @@ export const EXPECTED_MANIFEST: Manifest = {
handlerName: 'default.config.handler',
name: 'test-function',
sourceHandlerPath: 'src/logic-functions/test-function.function.ts',
toolInputSchema: { type: 'object', properties: {} },
timeoutSeconds: 2,
httpRouteTriggerSettings: {
forwardedRequestHeaders: ['signature'],
@@ -31,13 +31,16 @@ import {
type ObjectManifest,
type PageLayoutManifest,
type PageLayoutTabManifest,
type PostInstallLogicFunctionApplicationManifest,
type PreInstallLogicFunctionApplicationManifest,
type RoleManifest,
type SkillManifest,
type ViewManifest,
type PostInstallLogicFunctionApplicationManifest,
type PreInstallLogicFunctionApplicationManifest,
} from 'twenty-shared/application';
import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function';
import {
getInputSchemaFromSourceCode,
jsonSchemaToInputSchema,
} from 'twenty-shared/logic-function';
import { assertUnreachable } from 'twenty-shared/utils';
import { addMissingFieldOptionIds } from '@/cli/utilities/build/manifest/utils/add-missing-field-option-ids';
import { type PostInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/post-install-logic-function-config';
@@ -234,13 +237,43 @@ export const buildManifest = async (
const relativeFilePath = relative(appPath, filePath);
const toolInputSchema =
rest.toolInputSchema ??
(await getInputSchemaFromSourceCode(fileContent));
// 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 &&
!rest.workflowActionTriggerSettings.inputSchema)
? await getInputSchemaFromSourceCode(fileContent)
: null;
const toolTriggerSettings = rest.toolTriggerSettings
? {
...rest.toolTriggerSettings,
inputSchema:
rest.toolTriggerSettings.inputSchema ??
inferredJsonSchema ??
undefined,
}
: undefined;
const workflowActionTriggerSettings = rest.workflowActionTriggerSettings
? {
...rest.workflowActionTriggerSettings,
inputSchema:
rest.workflowActionTriggerSettings.inputSchema ??
(inferredJsonSchema
? jsonSchemaToInputSchema(inferredJsonSchema)
: undefined),
}
: undefined;
const config: LogicFunctionManifest = {
...rest,
toolInputSchema,
...(toolTriggerSettings ? { toolTriggerSettings } : {}),
...(workflowActionTriggerSettings
? { workflowActionTriggerSettings }
: {}),
handlerName: 'default.config.handler',
sourceHandlerPath: relativeFilePath,
builtHandlerPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
@@ -1,5 +1,4 @@
import { type LogicFunctionManifest } from 'twenty-shared/application';
import { type InputJsonSchema } from 'twenty-shared/logic-function';
export type LogicFunctionHandler = (...args: any[]) => any | Promise<any>;
@@ -9,8 +8,6 @@ export type LogicFunctionConfig = Omit<
| 'builtHandlerPath'
| 'builtHandlerChecksum'
| 'handlerName'
| 'toolInputSchema'
> & {
handler: LogicFunctionHandler;
toolInputSchema?: InputJsonSchema;
};
@@ -6,7 +6,8 @@ export type PreInstallLogicFunctionConfig = Omit<
| 'cronTriggerSettings'
| 'databaseEventTriggerSettings'
| 'httpRouteTriggerSettings'
| 'isTool'
| 'toolTriggerSettings'
| 'workflowActionTriggerSettings'
| 'handler'
> & {
handler: InstallHandler;
@@ -0,0 +1,27 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.3.0', 1797000001000)
export class AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."logicFunction" ADD "toolTriggerSettings" jsonb`,
);
await queryRunner.query(
`ALTER TABLE "core"."logicFunction" ADD "workflowActionTriggerSettings" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."logicFunction" DROP COLUMN "workflowActionTriggerSettings"`,
);
await queryRunner.query(
`ALTER TABLE "core"."logicFunction" DROP COLUMN "toolTriggerSettings"`,
);
}
}
@@ -0,0 +1,58 @@
import { DataSource, QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
// isTool=true previously exposed a function on both surfaces (AI tool and
// workflow node), so we populate both new triggers when migrating.
@RegisteredInstanceCommand('2.3.0', 1797000002000, { type: 'slow' })
export class MigrateToolTriggerSettingsSlowInstanceCommand
implements SlowInstanceCommand
{
async runDataMigration(dataSource: DataSource): Promise<void> {
const defaultJsonSchema = `'{"type":"object","properties":{}}'::jsonb`;
await dataSource.query(
`UPDATE "core"."logicFunction"
SET "toolTriggerSettings" = jsonb_build_object(
'inputSchema',
COALESCE("toolInputSchema", ${defaultJsonSchema})
),
"workflowActionTriggerSettings" = jsonb_build_object(
'inputSchema',
jsonb_build_array(
COALESCE("toolInputSchema", ${defaultJsonSchema})
)
)
WHERE "isTool" = true
AND "toolTriggerSettings" IS NULL
AND "workflowActionTriggerSettings" IS NULL`,
);
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."logicFunction" DROP COLUMN "isTool"`,
);
await queryRunner.query(
`ALTER TABLE "core"."logicFunction" DROP COLUMN "toolInputSchema"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."logicFunction" ADD "toolInputSchema" jsonb`,
);
await queryRunner.query(
`ALTER TABLE "core"."logicFunction" ADD "isTool" boolean NOT NULL DEFAULT false`,
);
// Best-effort reverse backfill so existing tools keep functioning if
// someone rolls back. Pulls the JSON schema out of toolTriggerSettings.
await queryRunner.query(
`UPDATE "core"."logicFunction"
SET "isTool" = true,
"toolInputSchema" = "toolTriggerSettings"->'inputSchema'
WHERE "toolTriggerSettings" IS NOT NULL`,
);
}
}
@@ -22,6 +22,8 @@ import { AddUpgradeMigrationWorkspaceIdIndexFastInstanceCommand } from 'src/data
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
import { AddDeletedAtToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777682000000-add-deleted-at-to-agent-chat-thread';
import { AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1797000001000-add-tool-and-workflow-action-trigger-settings';
import { MigrateToolTriggerSettingsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-slow-1797000002000-migrate-tool-trigger-settings';
import { ConnectionProviderSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity';
import { RemoveUserDefaultAvatarUrlFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777915958318-remove-user-default-avatar-url';
@@ -48,6 +50,8 @@ export const INSTANCE_COMMANDS = [
AddCacheTokensToAgentChatThreadFastInstanceCommand,
AddLogoToApplicationFastInstanceCommand,
AddDeletedAtToAgentChatThreadFastInstanceCommand,
AddToolAndWorkflowActionTriggerSettingsFastInstanceCommand,
MigrateToolTriggerSettingsSlowInstanceCommand,
ConnectionProviderSyncableEntityFastInstanceCommand,
RemoveUserDefaultAvatarUrlFastInstanceCommand,
];
@@ -28,13 +28,14 @@ export const fromLogicFunctionManifestToUniversalFlatLogicFunction = ({
builtHandlerPath: logicFunctionManifest.builtHandlerPath,
handlerName: logicFunctionManifest.handlerName,
checksum: logicFunctionManifest.builtHandlerChecksum,
toolInputSchema: logicFunctionManifest.toolInputSchema,
isTool: logicFunctionManifest.isTool ?? false,
cronTriggerSettings: logicFunctionManifest.cronTriggerSettings ?? null,
databaseEventTriggerSettings:
logicFunctionManifest.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings:
logicFunctionManifest.httpRouteTriggerSettings ?? null,
toolTriggerSettings: logicFunctionManifest.toolTriggerSettings ?? null,
workflowActionTriggerSettings:
logicFunctionManifest.workflowActionTriggerSettings ?? null,
isBuildUpToDate: true,
createdAt: now,
updatedAt: now,
@@ -0,0 +1,8 @@
// Refresh result returned by the app OAuth driver. Mirrors
// `ConnectedAccountTokens` from the central refresh manager but redeclared
// here so this engine-side driver has zero dependency on `modules/`.
export type AppOAuthTokens = {
accessToken: string;
refreshToken: string;
};
@@ -42,6 +42,48 @@ export class ApplicationVariableEntityService {
});
}
// Decrypted plaintext value. Server-side only — never expose via GraphQL.
// Used by trusted server flows that need the raw secret (e.g. exchanging an
// OAuth client secret with a third-party provider).
getRawValue(applicationVariable: ApplicationVariableEntity): string {
if (!applicationVariable.isSecret) {
return applicationVariable.value;
}
return this.secretEncryptionService.decrypt(applicationVariable.value);
}
async findOneByKey({
applicationId,
key,
}: {
applicationId: string;
key: string;
}): Promise<ApplicationVariableEntity | null> {
return this.applicationVariableRepository.findOne({
where: { applicationId, key },
});
}
async getRawValueByKeyOrThrow({
applicationId,
key,
}: {
applicationId: string;
key: string;
}): Promise<string> {
const variable = await this.findOneByKey({ applicationId, key });
if (!isDefined(variable)) {
throw new ApplicationVariableEntityException(
`Application variable "${key}" not found for application ${applicationId}`,
ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND,
);
}
return this.getRawValue(variable);
}
async update({
key,
plainTextValue,
@@ -58,7 +58,9 @@ export class LogicFunctionToolProvider implements ToolProvider {
flatLogicFunctionMaps.byUniversalIdentifier,
).filter(
(fn): fn is FlatLogicFunction =>
isDefined(fn) && fn.isTool === true && fn.deletedAt === null,
isDefined(fn) &&
isDefined(fn.toolTriggerSettings) &&
fn.deletedAt === null,
);
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
@@ -79,12 +81,12 @@ export class LogicFunctionToolProvider implements ToolProvider {
};
if (includeSchemas) {
// Logic functions already store JSON Schema -- use it directly
const inputSchema =
(logicFunction.toolInputSchema as object) ??
DEFAULT_TOOL_INPUT_SCHEMA;
descriptors.push({ ...base, inputSchema });
descriptors.push({
...base,
inputSchema:
(logicFunction.toolTriggerSettings?.inputSchema as object) ??
DEFAULT_TOOL_INPUT_SCHEMA,
});
} else {
descriptors.push(base);
}
@@ -114,20 +114,21 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"checksum",
"sourceHandlerPath",
"handlerName",
"toolInputSchema",
"isTool",
"isBuildUpToDate",
"deletedAt",
"cronTriggerSettings",
"databaseEventTriggerSettings",
"httpRouteTriggerSettings",
"toolTriggerSettings",
"workflowActionTriggerSettings",
"builtHandlerPath",
],
"propertiesToStringify": [
"toolInputSchema",
"cronTriggerSettings",
"databaseEventTriggerSettings",
"httpRouteTriggerSettings",
"toolTriggerSettings",
"workflowActionTriggerSettings",
],
},
"navigationMenuItem": {
@@ -576,16 +576,6 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
toolInputSchema: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
isTool: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
isBuildUpToDate: {
toCompare: true,
toStringify: false,
@@ -611,6 +601,16 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: true,
universalProperty: undefined,
},
toolTriggerSettings: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
workflowActionTriggerSettings: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
createdAt: {
toCompare: false,
toStringify: false,
@@ -7,10 +7,10 @@ export const FLAT_LOGIC_FUNCTION_EDITABLE_PROPERTIES = [
'checksum',
'sourceHandlerPath',
'handlerName',
'toolInputSchema',
'isTool',
'cronTriggerSettings',
'databaseEventTriggerSettings',
'httpRouteTriggerSettings',
'toolTriggerSettings',
'workflowActionTriggerSettings',
'isBuildUpToDate',
] as const satisfies MetadataEntityPropertyName<'logicFunction'>[];
@@ -1,7 +1,6 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsObject,
@@ -16,6 +15,8 @@ import {
CronTriggerSettings,
DatabaseEventTriggerSettings,
HttpRouteTriggerSettings,
ToolTriggerSettings,
WorkflowActionTriggerSettings,
} from 'twenty-shared/application';
import type { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@@ -51,16 +52,6 @@ export class CreateLogicFunctionFromSourceInput {
@IsOptional()
timeoutSeconds?: number;
@Field(() => graphqlTypeJson, { nullable: true })
@IsObject()
@IsOptional()
toolInputSchema?: object;
@IsBoolean()
@Field({ nullable: true })
@IsOptional()
isTool?: boolean;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
@@ -80,4 +71,14 @@ export class CreateLogicFunctionFromSourceInput {
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
httpRouteTriggerSettings?: JsonbProperty<HttpRouteTriggerSettings>;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
toolTriggerSettings?: JsonbProperty<ToolTriggerSettings>;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
workflowActionTriggerSettings?: JsonbProperty<WorkflowActionTriggerSettings>;
}
@@ -1,7 +1,6 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsObject, IsString, Matches } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
import { IsString, Matches } from 'class-validator';
import { HANDLER_NAME_REGEX } from 'src/engine/metadata-modules/logic-function/constants/handler.contant';
@@ -11,10 +10,6 @@ export class LogicFunctionSourceInput {
@Field({ nullable: false })
sourceHandlerCode: string;
@Field(() => graphqlTypeJson, { nullable: false })
@IsObject()
toolInputSchema: object;
@IsString()
@Matches(HANDLER_NAME_REGEX, {
message: 'handlerName must be a valid JavaScript identifier or dotted path',
@@ -6,7 +6,6 @@ import {
QueryOptions,
} from '@ptc-org/nestjs-query-graphql';
import {
IsBoolean,
IsDateString,
IsNotEmpty,
IsNumber,
@@ -20,10 +19,10 @@ import {
CronTriggerSettings,
DatabaseEventTriggerSettings,
HttpRouteTriggerSettings,
ToolTriggerSettings,
WorkflowActionTriggerSettings,
} from 'twenty-shared/application';
import type { InputJsonSchema } from 'twenty-shared/logic-function';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('LogicFunction')
@@ -68,15 +67,6 @@ export class LogicFunctionDTO {
@Field()
handlerName: string;
@IsObject()
@IsOptional()
@Field(() => graphqlTypeJson, { nullable: true })
toolInputSchema?: InputJsonSchema;
@IsBoolean()
@Field()
isTool: boolean;
@IsObject()
@IsOptional()
@Field(() => graphqlTypeJson, { nullable: true })
@@ -92,6 +82,16 @@ export class LogicFunctionDTO {
@Field(() => graphqlTypeJson, { nullable: true })
httpRouteTriggerSettings?: HttpRouteTriggerSettings;
@IsObject()
@IsOptional()
@Field(() => graphqlTypeJson, { nullable: true })
toolTriggerSettings?: ToolTriggerSettings;
@IsObject()
@IsOptional()
@Field(() => graphqlTypeJson, { nullable: true })
workflowActionTriggerSettings?: WorkflowActionTriggerSettings;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
@@ -2,7 +2,6 @@ import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsObject,
@@ -19,6 +18,8 @@ import {
CronTriggerSettings,
DatabaseEventTriggerSettings,
HttpRouteTriggerSettings,
ToolTriggerSettings,
WorkflowActionTriggerSettings,
} from 'twenty-shared/application';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -49,11 +50,6 @@ class UpdateLogicFunctionFromSourceInputUpdates {
@IsOptional()
sourceHandlerCode?: string;
@Field(() => graphqlTypeJson, { nullable: true })
@IsObject()
@IsOptional()
toolInputSchema?: object;
@IsString()
@Matches(HANDLER_NAME_REGEX, {
message: 'handlerName must be a valid JavaScript identifier or dotted path',
@@ -67,11 +63,6 @@ class UpdateLogicFunctionFromSourceInputUpdates {
@IsOptional()
sourceHandlerPath?: string;
@IsBoolean()
@Field({ nullable: true })
@IsOptional()
isTool?: boolean;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
@@ -86,6 +77,16 @@ class UpdateLogicFunctionFromSourceInputUpdates {
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
httpRouteTriggerSettings?: JsonbProperty<HttpRouteTriggerSettings>;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
toolTriggerSettings?: JsonbProperty<ToolTriggerSettings>;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
workflowActionTriggerSettings?: JsonbProperty<WorkflowActionTriggerSettings>;
}
@InputType()
@@ -12,8 +12,9 @@ import {
CronTriggerSettings,
DatabaseEventTriggerSettings,
HttpRouteTriggerSettings,
ToolTriggerSettings,
WorkflowActionTriggerSettings,
} from 'twenty-shared/application';
import { type InputJsonSchema } from 'twenty-shared/logic-function';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@@ -59,12 +60,6 @@ export class LogicFunctionEntity
@Column({ nullable: true, type: 'text' })
checksum: string | null;
@Column({ nullable: true, type: 'jsonb' })
toolInputSchema: JsonbProperty<InputJsonSchema> | null;
@Column({ nullable: false, default: false })
isTool: boolean;
@Column({ nullable: false, type: 'boolean', default: true })
isBuildUpToDate: boolean;
@@ -77,6 +72,12 @@ export class LogicFunctionEntity
@Column({ nullable: true, type: 'jsonb' })
httpRouteTriggerSettings: JsonbProperty<HttpRouteTriggerSettings> | null;
@Column({ nullable: true, type: 'jsonb' })
toolTriggerSettings: JsonbProperty<ToolTriggerSettings> | null;
@Column({ nullable: true, type: 'jsonb' })
workflowActionTriggerSettings: JsonbProperty<WorkflowActionTriggerSettings> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -4,7 +4,6 @@ import crypto from 'crypto';
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared/utils';
import { SEED_LOGIC_FUNCTION_INPUT_SCHEMA } from 'twenty-shared/logic-function';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
@@ -76,7 +75,6 @@ export class LogicFunctionFromSourceService {
builtHandlerPath,
handlerName: input.source.handlerName,
checksum: null,
toolInputSchema: input.source.toolInputSchema,
isBuildUpToDate: false,
applicationUniversalIdentifier:
ownerFlatApplication.universalIdentifier,
@@ -111,7 +109,6 @@ export class LogicFunctionFromSourceService {
builtHandlerPath,
handlerName,
checksum,
toolInputSchema: SEED_LOGIC_FUNCTION_INPUT_SCHEMA,
isBuildUpToDate: true,
applicationUniversalIdentifier:
ownerFlatApplication.universalIdentifier,
@@ -170,8 +167,6 @@ export class LogicFunctionFromSourceService {
name: existingLogicFunction.name,
description: existingLogicFunction.description,
timeoutSeconds: existingLogicFunction.timeoutSeconds,
toolInputSchema: existingLogicFunction.toolInputSchema,
isTool: existingLogicFunction.isTool,
isBuildUpToDate: existingLogicFunction.isBuildUpToDate,
checksum: existingLogicFunction.checksum,
handlerName: existingLogicFunction.handlerName,
@@ -182,6 +177,9 @@ export class LogicFunctionFromSourceService {
existingLogicFunction.databaseEventTriggerSettings,
httpRouteTriggerSettings:
existingLogicFunction.httpRouteTriggerSettings,
toolTriggerSettings: existingLogicFunction.toolTriggerSettings,
workflowActionTriggerSettings:
existingLogicFunction.workflowActionTriggerSettings,
applicationUniversalIdentifier:
ownerFlatApplication.universalIdentifier,
});
@@ -39,8 +39,6 @@ export const buildUniversalFlatLogicFunctionToCreate = (
runtime: LogicFunctionRuntime.NODE22,
timeoutSeconds: input.timeoutSeconds ?? 300,
checksum: input.checksum ?? null,
toolInputSchema: input.toolInputSchema ?? null,
isTool: input.isTool ?? false,
isBuildUpToDate: input.isBuildUpToDate,
handlerName: input.handlerName,
sourceHandlerPath: input.sourceHandlerPath,
@@ -48,6 +46,8 @@ export const buildUniversalFlatLogicFunctionToCreate = (
cronTriggerSettings: input.cronTriggerSettings ?? null,
databaseEventTriggerSettings: input.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings: input.httpRouteTriggerSettings ?? null,
toolTriggerSettings: input.toolTriggerSettings ?? null,
workflowActionTriggerSettings: input.workflowActionTriggerSettings ?? null,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -12,7 +12,6 @@ export const fromCreateLogicFunctionFromSourceInputToUniversalFlatLogicFunctionT
builtHandlerPath,
handlerName,
checksum,
toolInputSchema,
isBuildUpToDate,
applicationUniversalIdentifier,
}: {
@@ -21,7 +20,6 @@ export const fromCreateLogicFunctionFromSourceInputToUniversalFlatLogicFunctionT
builtHandlerPath: string;
handlerName: string;
checksum: string | null;
toolInputSchema: object | null;
isBuildUpToDate: boolean;
applicationUniversalIdentifier: string;
}): UniversalFlatLogicFunction & { id: string } => {
@@ -44,8 +42,6 @@ export const fromCreateLogicFunctionFromSourceInputToUniversalFlatLogicFunctionT
runtime: LogicFunctionRuntime.NODE22,
timeoutSeconds: createLogicFunctionFromSourceInput.timeoutSeconds ?? 300,
checksum,
toolInputSchema,
isTool: createLogicFunctionFromSourceInput.isTool ?? false,
isBuildUpToDate,
handlerName,
sourceHandlerPath,
@@ -56,6 +52,11 @@ export const fromCreateLogicFunctionFromSourceInputToUniversalFlatLogicFunctionT
createLogicFunctionFromSourceInput.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings:
createLogicFunctionFromSourceInput.httpRouteTriggerSettings ?? null,
toolTriggerSettings:
createLogicFunctionFromSourceInput.toolTriggerSettings ?? null,
workflowActionTriggerSettings:
createLogicFunctionFromSourceInput.workflowActionTriggerSettings ??
null,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -15,8 +15,6 @@ export const fromFlatLogicFunctionToLogicFunctionDto = ({
timeoutSeconds: flatLogicFunction.timeoutSeconds,
sourceHandlerPath: flatLogicFunction.sourceHandlerPath,
handlerName: flatLogicFunction.handlerName,
toolInputSchema: flatLogicFunction.toolInputSchema ?? undefined,
isTool: flatLogicFunction.isTool,
applicationId: flatLogicFunction.applicationId ?? undefined,
workspaceId: flatLogicFunction.workspaceId,
createdAt: new Date(flatLogicFunction.createdAt),
@@ -26,5 +24,8 @@ export const fromFlatLogicFunctionToLogicFunctionDto = ({
flatLogicFunction.databaseEventTriggerSettings ?? undefined,
httpRouteTriggerSettings:
flatLogicFunction.httpRouteTriggerSettings ?? undefined,
toolTriggerSettings: flatLogicFunction.toolTriggerSettings ?? undefined,
workflowActionTriggerSettings:
flatLogicFunction.workflowActionTriggerSettings ?? undefined,
};
};
@@ -45,10 +45,8 @@ export class PrefillLogicFunctionService {
id: definition.id,
name: definition.name,
description: definition.description,
toolInputSchema: definition.toolInputSchema,
source: {
sourceHandlerCode: definition.sourceHandlerCode,
toolInputSchema: definition.toolInputSchema,
handlerName: 'main',
},
},
@@ -20,42 +20,6 @@ export const getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionIds = (
),
});
const EXTRACT_DOMAIN_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
email: {
type: 'string',
},
},
required: ['email'],
};
const IS_PERSONAL_EMAIL_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
primaryEmail: {
type: 'string',
},
},
required: ['primaryEmail'],
};
const FIND_MATCHING_COMPANY_BY_DOMAIN_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
companies: {
type: 'array',
items: {
type: 'object',
},
},
domain: {
type: 'string',
},
},
required: ['companies', 'domain'],
};
const EXTRACT_DOMAIN_LOGIC_FUNCTION_SOURCE = `const psl = require('psl');
export const main = async (params) => {
@@ -184,7 +148,6 @@ export type PrefilledWorkflowCodeStepLogicFunctionDefinition = {
name: string;
description: string;
sourceHandlerCode: string;
toolInputSchema: object;
};
export const getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions =
@@ -203,7 +166,6 @@ export const getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions
description:
'Extracts a normalized company domain and URL from a person email address.',
sourceHandlerCode: EXTRACT_DOMAIN_LOGIC_FUNCTION_SOURCE,
toolInputSchema: EXTRACT_DOMAIN_TOOL_INPUT_SCHEMA,
},
{
id: findMatchingCompanyByDomainLogicFunctionId,
@@ -212,7 +174,6 @@ export const getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions
'Finds an existing company whose website matches a normalized registrable domain.',
sourceHandlerCode:
FIND_MATCHING_COMPANY_BY_DOMAIN_LOGIC_FUNCTION_SOURCE,
toolInputSchema: FIND_MATCHING_COMPANY_BY_DOMAIN_TOOL_INPUT_SCHEMA,
},
{
id: isPersonalEmailLogicFunctionId,
@@ -220,7 +181,6 @@ export const getCreateCompanyWhenAddingNewPersonCodeStepLogicFunctionDefinitions
description:
'Detects whether an email address belongs to a common personal email provider.',
sourceHandlerCode: IS_PERSONAL_EMAIL_LOGIC_FUNCTION_SOURCE,
toolInputSchema: IS_PERSONAL_EMAIL_TOOL_INPUT_SCHEMA,
},
];
};
@@ -54,8 +54,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
builtHandlerPath: 'index.mjs',
handlerName: 'main',
checksum: null,
toolInputSchema: null,
isTool: false,
toolTriggerSettings: null,
workflowActionTriggerSettings: null,
universalIdentifier: 'universal-id',
applicationId: 'application-id',
cronTriggerSettings: null,
@@ -77,8 +77,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
builtHandlerPath: 'index.mjs',
handlerName: 'main',
checksum: null,
toolInputSchema: null,
isTool: false,
toolTriggerSettings: null,
workflowActionTriggerSettings: null,
universalIdentifier: 'universal-id',
applicationId: 'application-id',
cronTriggerSettings: null,
@@ -182,10 +182,13 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
input: {
logicFunctionId: newLogicFunction.id,
logicFunctionInput: isDefined(newLogicFunction.toolInputSchema)
? (getFunctionInputFromInputSchema([
newLogicFunction.toolInputSchema,
])[0] ?? {})
logicFunctionInput: isDefined(
newLogicFunction.workflowActionTriggerSettings?.inputSchema,
)
? (getFunctionInputFromInputSchema(
newLogicFunction.workflowActionTriggerSettings
.inputSchema,
)[0] ?? {})
: {},
},
},
@@ -235,10 +238,13 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
input: {
logicFunctionId,
logicFunctionInput: isDefined(flatLogicFunction.toolInputSchema)
? (getFunctionInputFromInputSchema([
flatLogicFunction.toolInputSchema,
])[0] ?? {})
logicFunctionInput: isDefined(
flatLogicFunction.workflowActionTriggerSettings?.inputSchema,
)
? (getFunctionInputFromInputSchema(
flatLogicFunction.workflowActionTriggerSettings
.inputSchema,
)[0] ?? {})
: {},
},
},
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { resolveInput } from 'twenty-shared/utils';
import { isDefined, resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
@@ -69,6 +69,13 @@ export class LogicFunctionWorkflowAction implements WorkflowAction {
);
}
if (!isDefined(logicFunction.workflowActionTriggerSettings)) {
throw new WorkflowStepExecutorException(
`Logic function ${logicFunction.name} is not exposed as a workflow action`,
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: workflowActionInput.logicFunctionId,
workspaceId,
@@ -15,7 +15,7 @@ export const createListLogicFunctionToolsTool = (
) => ({
name: 'list_logic_function_tools' as const,
description:
'List all logic functions marked as tools that can be added as LOGIC_FUNCTION steps in workflows. Returns their IDs, names, and descriptions.',
'List all logic functions exposed as workflow actions, which can be added as LOGIC_FUNCTION steps in workflows. Returns their IDs, names, and descriptions.',
inputSchema: listLogicFunctionToolsSchema,
execute: async () => {
const { flatLogicFunctionMaps } =
@@ -26,16 +26,18 @@ export const createListLogicFunctionToolsTool = (
},
);
const toolFunctions = Object.values(
const workflowActionFunctions = Object.values(
flatLogicFunctionMaps.byUniversalIdentifier,
).filter(
(fn): fn is FlatLogicFunction =>
isDefined(fn) && fn.isTool === true && fn.deletedAt === null,
isDefined(fn) &&
isDefined(fn.workflowActionTriggerSettings) &&
fn.deletedAt === null,
);
return {
success: true,
logicFunctions: toolFunctions.map((fn) => ({
logicFunctions: workflowActionFunctions.map((fn) => ({
id: fn.id,
name: fn.name,
description: fn.description,
@@ -65,10 +65,6 @@ describe('Logic Function Execution', () => {
id: createData.createOneLogicFunction.id,
update: {
sourceHandlerCode: DEFAULT_TEMPLATE_FUNCTION_CODE,
toolInputSchema: {
type: 'object',
properties: { message: { type: 'string' } },
},
},
},
expectToFail: false,
@@ -115,10 +111,6 @@ describe('Logic Function Execution', () => {
id: createData.createOneLogicFunction.id,
update: {
sourceHandlerCode: EXTERNAL_PACKAGES_FUNCTION_CODE,
toolInputSchema: {
type: 'object',
properties: { message: { type: 'string' } },
},
},
},
expectToFail: false,
@@ -207,10 +199,6 @@ describe('Logic Function Execution', () => {
name: 'Test Default Function',
source: {
sourceHandlerCode: DEFAULT_TEMPLATE_FUNCTION_CODE,
toolInputSchema: {
type: 'object',
properties: { message: { type: 'string' } },
},
handlerName: 'main',
},
},
@@ -263,10 +251,6 @@ describe('Logic Function Execution', () => {
id: createData.createOneLogicFunction.id,
update: {
sourceHandlerCode: ERROR_FUNCTION_CODE,
toolInputSchema: {
type: 'object',
properties: { message: { type: 'string' } },
},
},
},
expectToFail: false,
@@ -63,6 +63,7 @@ export type { ServerVariables } from './server-variables.type';
export type { SkillManifest } from './skillManifestType';
export type { StoredOAuthConnectionProviderConfig } from './storedOAuthConnectionProviderConfigType';
export type { SyncableEntityOptions } from './syncableEntityOptionsType';
export type { ToolTriggerSettings } from './toolTriggerSettingsType';
export type {
ViewManifestFilterValue,
ViewFieldManifest,
@@ -73,3 +74,4 @@ export type {
ViewSortManifest,
ViewManifest,
} from './viewManifestType';
export type { WorkflowActionTriggerSettings } from './workflowActionTriggerSettingsType';
@@ -1,6 +1,7 @@
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
import { type ToolTriggerSettings } from '@/application/toolTriggerSettingsType';
import { type WorkflowActionTriggerSettings } from '@/application/workflowActionTriggerSettingsType';
import { type HTTPMethod } from '@/types';
import { type InputJsonSchema } from '@/logic-function/input-json-schema.type';
export type LogicFunctionManifest = SyncableEntityOptions & {
name?: string;
@@ -9,12 +10,12 @@ export type LogicFunctionManifest = SyncableEntityOptions & {
cronTriggerSettings?: CronTriggerSettings;
databaseEventTriggerSettings?: DatabaseEventTriggerSettings;
httpRouteTriggerSettings?: HttpRouteTriggerSettings;
toolTriggerSettings?: ToolTriggerSettings;
workflowActionTriggerSettings?: WorkflowActionTriggerSettings;
sourceHandlerPath: string;
builtHandlerPath: string;
builtHandlerChecksum: string;
handlerName: string;
toolInputSchema: InputJsonSchema;
isTool?: boolean;
};
export type CronTriggerSettings = {
@@ -0,0 +1,9 @@
import { type InputJsonSchema } from '@/logic-function/input-json-schema.type';
// Exposes a logic function as an AI tool (chat / MCP / function calling).
// Uses standard JSON Schema -- the format LLMs natively understand.
// inputSchema is optional in the developer-facing SDK; the manifest
// builder fills it in by inferring from the handler source code when omitted.
export type ToolTriggerSettings = {
inputSchema?: InputJsonSchema;
};
@@ -0,0 +1,13 @@
import { type InputSchema } from '@/workflow/types/InputSchema';
// Exposes a logic function as a step in the visual workflow builder.
// Uses Twenty's rich InputSchema with FieldMetadataType support so the
// builder can render proper field editors, variable pickers, and labels.
// inputSchema is optional in the developer-facing SDK; the manifest builder
// fills it in by inferring from the handler source code when omitted.
export type WorkflowActionTriggerSettings = {
inputSchema?: InputSchema;
outputSchema?: InputSchema;
icon?: string;
label?: string;
};
@@ -0,0 +1,84 @@
import { jsonSchemaToInputSchema } from '@/logic-function/json-schema-to-input-schema';
describe('jsonSchemaToInputSchema', () => {
it('wraps a JSON Schema object into a single-element InputSchema array', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
properties: {
name: { type: 'string', description: 'A name' },
age: { type: 'number' },
},
required: ['name'],
});
expect(result).toEqual([
{
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
},
]);
});
it('maps integer to number', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
properties: { count: { type: 'integer' } },
});
expect(result[0].properties).toEqual({ count: { type: 'number' } });
});
it('maps null to unknown', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
properties: { value: { type: 'null' } },
});
expect(result[0].properties).toEqual({ value: { type: 'unknown' } });
});
it('preserves array items', () => {
const result = jsonSchemaToInputSchema({
type: 'array',
items: { type: 'string' },
});
expect(result).toEqual([
{
type: 'array',
items: { type: 'string' },
},
]);
});
it('preserves enum on string properties', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
properties: {
color: { type: 'string', enum: ['red', 'green', 'blue'] },
},
});
expect(result[0].properties?.color).toEqual({
type: 'string',
enum: ['red', 'green', 'blue'],
});
});
it('drops non-string enum values silently', () => {
const result = jsonSchemaToInputSchema({
type: 'object',
properties: {
mixed: { type: 'string', enum: ['a', 1, true, 'b'] },
},
});
expect(result[0].properties?.mixed).toEqual({
type: 'string',
enum: ['a', 'b'],
});
});
});
@@ -1,9 +0,0 @@
import { type InputJsonSchema } from '@/logic-function';
export const SEED_LOGIC_FUNCTION_INPUT_SCHEMA: InputJsonSchema = {
type: 'object',
properties: {
a: { type: 'string' },
b: { type: 'number' },
},
};
@@ -8,7 +8,7 @@
*/
export { DEFAULT_TOOL_INPUT_SCHEMA } from './constants/DefaultToolInputSchema';
export { SEED_LOGIC_FUNCTION_INPUT_SCHEMA } from './constants/SeedLogicFunctionInputSchema';
export { getInputSchemaFromSourceCode } from './get-input-schema-from-source-code';
export { getOutputSchemaFromValue } from './get-output-schema-from-value';
export type { InputJsonSchema } from './input-json-schema.type';
export { jsonSchemaToInputSchema } from './json-schema-to-input-schema';
@@ -0,0 +1,59 @@
import { type InputJsonSchema } from '@/logic-function/input-json-schema.type';
import {
type InputSchema,
type InputSchemaProperty,
} from '@/workflow/types/InputSchema';
const convertProperty = (jsonSchema: InputJsonSchema): InputSchemaProperty => {
const property: InputSchemaProperty = { type: 'unknown' };
switch (jsonSchema.type) {
case 'string':
property.type = 'string';
break;
case 'number':
case 'integer':
property.type = 'number';
break;
case 'boolean':
property.type = 'boolean';
break;
case 'array':
property.type = 'array';
if (jsonSchema.items) {
property.items = convertProperty(jsonSchema.items);
}
break;
case 'object':
property.type = 'object';
if (jsonSchema.properties) {
property.properties = Object.fromEntries(
Object.entries(jsonSchema.properties).map(([key, value]) => [
key,
convertProperty(value),
]),
);
}
break;
case 'null':
default:
property.type = 'unknown';
}
if (Array.isArray(jsonSchema.enum)) {
property.enum = jsonSchema.enum.filter(
(value): value is string => typeof value === 'string',
);
}
return property;
};
// Wraps in a single-element array because Twenty's InputSchema represents
// the parameter list of a function -- logic functions take a single params
// object, hence a one-element array containing it.
export const jsonSchemaToInputSchema = (
jsonSchema: InputJsonSchema,
): InputSchema => {
return [convertProperty(jsonSchema)];
};