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:
+58
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -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),
|
||||
};
|
||||
|
||||
+20
-39
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
-9
@@ -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,
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user