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
parent 36452ecc8b
commit 53fdac1417
66 changed files with 820 additions and 357 deletions
@@ -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,
};
};