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
@@ -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}
/>
);
};