feat(ai-chat): add navigation menu item + webhook tool providers (#20759)

## Summary

Exposes two Twenty primitives to the AI chat that it could not
previously manage:

- **Navigation menu items** — workspace nav and personal favorites
(favorites are just nav items with `scope: 'user'`).
- **Webhooks** — full CRUD with a structured operations input (record +
metadata events).

Page layouts and workflow runs were originally in this PR but have been
split out — they touch heavier surfaces (21 widget configurations and
the workflow runner cycle, respectively) and deserve their own focused
PRs.

### Tool inventory (8 new tools across 2 providers)

| Provider | Tools |
|---|---|
| NavigationMenuItem | `list_`, `create_`, `update_`,
`delete_navigation_menu_item` |
| Webhook | `list_`, `create_`, `update_`, `delete_webhook` |

### Design notes

- Both providers follow the established **view-style pattern**: tool
workspace service lives in the entity module's `tools/` folder, is
provided + exported by the entity module, and `ToolProviderModule`
imports the entity module. No `@Global()` modules or injection tokens
introduced.
- `create_navigation_menu_item` uses a Zod `discriminatedUnion` on
`type` (`FOLDER` / `LINK` / `OBJECT` / `VIEW` / `RECORD` /
`PAGE_LAYOUT`). `scope: 'workspace' | 'user'` switches between shared
nav and personal favorites — the underlying
`NavigationMenuItemAccessService` enforces LAYOUTS for workspace writes.
- Webhook operations accept both record events (`{kind:'record', object,
event}` → `<object>.<event>`) and metadata events (`{kind:'metadata',
metadataName, operation}` → `metadata.<metadataName>.<operation>`).
- Permissions reuse existing flags (`LAYOUTS`, `API_KEYS_AND_WEBHOOKS`).
No new permission flags, no migrations.

### Category cleanup

- New: `ToolCategory.NAVIGATION_MENU_ITEM`, `ToolCategory.WEBHOOK`.
- `ToolCategory.VIEW_FIELD` → folded into `VIEW`. Same permission gate,
same domain — separate category was organizational drift.
- `navigate_app` action stays in `ToolCategory.ACTION` where it belongs.

### System prompt addition


[chat-system-prompts.const.ts](packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts)
now teaches the AI:
- Favorites are nav items with `scope: 'user'`.
- A default OBJECT nav item is auto-created with
`create_object_metadata` — don't double-create.

### One file = one export

Every new schema / type / util file has exactly one top-level export.

## Test plan

- [ ] `npx nx typecheck twenty-server` — passes
- [ ] Spin up locally and exercise via AI chat:
- [ ] "Pin the Companies view to my favorites in a folder called
Important." → `create_navigation_menu_item` (FOLDER, user) then (VIEW,
user, folderId)
- [ ] "Register a webhook to https://example.com firing when any person
is created or updated." → `create_webhook` with discriminated operations
- [ ] Verify workspace-scoped nav writes are denied for a user without
LAYOUTS permission
- [ ] Verify user-scoped nav writes work without LAYOUTS permission

## Follow-ups (separate PRs)

- Page layout tools (record-page, record-index, standalone) — needs
widget-config strategy.
- Workflow run tools (list, get, run, stop) — uses the workflow-runner
cycle path.
- Dashboard / page-layout tool unification —
`DashboardToolWorkspaceService` and a future
`PageLayoutToolWorkspaceService` both inject the same trio
(PageLayout/Tab/Widget services).
- Webhook Settings page reads from raw Apollo query — switch to the
metadata store so it refreshes when the AI mutates webhooks.
This commit is contained in:
Félix Malfait
2026-05-22 17:27:06 +02:00
committed by GitHub
parent e3c79c803c
commit de044f4b45
31 changed files with 1323 additions and 378 deletions
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { ToolCategory } from 'twenty-shared/ai';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { NavigationMenuItemToolWorkspaceService } from 'src/engine/metadata-modules/navigation-menu-item/tools/services/navigation-menu-item-tool.workspace-service';
@Injectable()
export class NavigationMenuItemToolProvider implements ToolProvider {
readonly category = ToolCategory.NAVIGATION_MENU_ITEM;
constructor(
private readonly navigationMenuItemToolService: NavigationMenuItemToolWorkspaceService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
return true;
}
async generateDescriptors(
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
return toolSetToDescriptors(
this.buildToolSet(context),
ToolCategory.NAVIGATION_MENU_ITEM,
{ includeSchemas: options?.includeSchemas ?? true },
);
}
async executeStaticTool(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
return executeToolFromToolSet(
this.buildToolSet(context),
toolName,
args,
ToolCategory.NAVIGATION_MENU_ITEM,
);
}
private buildToolSet(context: ToolProviderContext): ToolSet {
return this.navigationMenuItemToolService.generateNavigationMenuItemTools(
context.workspaceId,
context.userWorkspaceId,
);
}
}
@@ -14,6 +14,7 @@ import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/ut
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory';
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory';
import { ViewSortToolsFactory } from 'src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@@ -24,6 +25,7 @@ export class ViewToolProvider implements ToolProvider {
constructor(
private readonly viewToolsFactory: ViewToolsFactory,
private readonly viewFieldToolsFactory: ViewFieldToolsFactory,
private readonly viewFilterToolsFactory: ViewFilterToolsFactory,
private readonly viewSortToolsFactory: ViewSortToolsFactory,
private readonly permissionsService: PermissionsService,
@@ -63,6 +65,7 @@ export class ViewToolProvider implements ToolProvider {
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
),
...this.viewFieldToolsFactory.generateReadTools(context.workspaceId),
...this.viewFilterToolsFactory.generateReadTools(context.workspaceId),
...this.viewSortToolsFactory.generateReadTools(context.workspaceId),
};
@@ -83,6 +86,7 @@ export class ViewToolProvider implements ToolProvider {
context.workspaceId,
workspaceMemberId ?? undefined,
),
...this.viewFieldToolsFactory.generateWriteTools(context.workspaceId),
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
...this.viewSortToolsFactory.generateWriteTools(context.workspaceId),
};
@@ -1,44 +1,46 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { ToolCategory } from 'twenty-shared/ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory';
import { WebhookToolWorkspaceService } from 'src/engine/metadata-modules/webhook/tools/services/webhook-tool.workspace-service';
@Injectable()
export class ViewFieldToolProvider implements ToolProvider {
readonly category = ToolCategory.VIEW_FIELD;
export class WebhookToolProvider implements ToolProvider {
readonly category = ToolCategory.WEBHOOK;
constructor(
private readonly viewFieldToolsFactory: ViewFieldToolsFactory,
private readonly webhookToolService: WebhookToolWorkspaceService,
private readonly permissionsService: PermissionsService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
return true;
async isAvailable(context: ToolProviderContext): Promise<boolean> {
return this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.API_KEYS_AND_WEBHOOKS,
);
}
async generateDescriptors(
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const toolSet = await this.buildToolSet(context);
return toolSetToDescriptors(toolSet, ToolCategory.VIEW_FIELD, {
includeSchemas: options?.includeSchemas ?? true,
icon: 'IconTable',
});
return toolSetToDescriptors(
this.buildToolSet(context),
ToolCategory.WEBHOOK,
{ includeSchemas: options?.includeSchemas ?? true },
);
}
async executeStaticTool(
@@ -46,36 +48,15 @@ export class ViewFieldToolProvider implements ToolProvider {
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
const toolSet = await this.buildToolSet(context);
return executeToolFromToolSet(
toolSet,
this.buildToolSet(context),
toolName,
args,
ToolCategory.VIEW_FIELD,
ToolCategory.WEBHOOK,
);
}
private async buildToolSet(context: ToolProviderContext): Promise<ToolSet> {
const readTools = this.viewFieldToolsFactory.generateReadTools(
context.workspaceId,
);
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.VIEWS,
);
if (!hasViewPermission) {
return readTools;
}
const writeTools = this.viewFieldToolsFactory.generateWriteTools(
context.workspaceId,
);
return { ...readTools, ...writeTools };
private buildToolSet(context: ToolProviderContext): ToolSet {
return this.webhookToolService.generateWebhookTools(context.workspaceId);
}
}
@@ -9,8 +9,9 @@ import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/prov
import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider';
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
import { NativeToolBinderService } from 'src/engine/core-modules/tool-provider/native/native-tool-binder.service';
import { ViewFieldToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-field-tool.provider';
import { NavigationMenuItemToolProvider } from 'src/engine/core-modules/tool-provider/providers/navigation-menu-item-tool.provider';
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
import { WebhookToolProvider } from 'src/engine/core-modules/tool-provider/providers/webhook-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
@@ -20,6 +21,7 @@ import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-mode
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -27,15 +29,21 @@ import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-fie
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WebhookModule } from 'src/engine/metadata-modules/webhook/webhook.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { ToolIndexResolver } from './resolvers/tool-index.resolver';
import { ToolRegistryService } from './services/tool-registry.service';
// NOTE: This module does NOT import WorkflowToolsModule or DashboardToolsModule to avoid
// circular dependencies. Instead, they are @Global() modules that provide their tokens.
// When imported anywhere in the app (e.g., AiChatModule), the tokens become available
// globally to their respective providers via @Optional() injection.
// NOTE: This module does NOT import WorkflowToolsModule or DashboardToolsModule
// directly: their service graphs transitively reach AiAgentExecutionModule which
// forwardRef's back into ToolProviderModule. Those two @Global() modules provide
// a service token that their respective providers consume via @Optional()
// @Inject, breaking the cycle.
//
// Webhook and NavigationMenuItem do NOT have that cycle, so we import their
// entity modules directly and the providers inject the services the normal way
// (same pattern as views/objects/metadata).
@Module({
imports: [
@@ -53,6 +61,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
LogicFunctionModule,
NavigationMenuItemModule,
WebhookModule,
UserRoleModule,
TypeOrmModule.forFeature([UserEntity]),
],
@@ -64,9 +74,10 @@ import { ToolRegistryService } from './services/tool-registry.service';
DatabaseToolProvider,
MetadataToolProvider,
NativeToolBinderService,
NavigationMenuItemToolProvider,
LogicFunctionToolProvider,
ViewFieldToolProvider,
ViewToolProvider,
WebhookToolProvider,
WorkflowToolProvider,
{
// TOOL_PROVIDERS contains only providers implementing ToolProvider
@@ -80,8 +91,9 @@ import { ToolRegistryService } from './services/tool-registry.service';
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
logicFunctionProvider: LogicFunctionToolProvider,
viewFieldProvider: ViewFieldToolProvider,
navigationMenuItemProvider: NavigationMenuItemToolProvider,
viewProvider: ViewToolProvider,
webhookProvider: WebhookToolProvider,
workflowProvider: WorkflowToolProvider,
) => [
actionProvider,
@@ -89,8 +101,9 @@ import { ToolRegistryService } from './services/tool-registry.service';
databaseProvider,
metadataProvider,
logicFunctionProvider,
viewFieldProvider,
navigationMenuItemProvider,
viewProvider,
webhookProvider,
workflowProvider,
],
inject: [
@@ -99,8 +112,9 @@ import { ToolRegistryService } from './services/tool-registry.service';
DatabaseToolProvider,
MetadataToolProvider,
LogicFunctionToolProvider,
ViewFieldToolProvider,
NavigationMenuItemToolProvider,
ViewToolProvider,
WebhookToolProvider,
WorkflowToolProvider,
],
},