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,
],
},
@@ -50,6 +50,11 @@ For simple CRUD operations (find/create/update/delete a record), you do NOT need
- If a tool fails, analyze the error, adjust parameters, and try again
- Don't give up after first failure — be persistent and try alternative approaches
- Validate assumptions before making changes
## Twenty primitives the AI commonly mixes up
- **Favorites are navigation menu items.** Twenty has no separate "Favorites" concept. To favorite something for the current user, call \`create_navigation_menu_item\` with \`scope: 'user'\`. Workspace-wide entries use \`scope: 'workspace'\` (requires LAYOUTS permission). Both are the same primitive — do not look for a separate favorites tool.
- **A default OBJECT navigation menu item is auto-created with \`create_object_metadata\`.** Don't immediately create another OBJECT item for the new object — only add a follow-up navigation item when the user is asking to pin a *different* view, folder, link, record, or page layout.
`,
// Browsing context hint
@@ -311,13 +311,15 @@ ${tools
case ToolCategory.METADATA:
return 'Metadata Tools (schema management)';
case ToolCategory.VIEW:
return 'View Tools (manage views, filters, and sorts)';
return 'View Tools (manage views, fields, filters, and sorts)';
case ToolCategory.DASHBOARD:
return 'Dashboard Tools (create/manage dashboards)';
case ToolCategory.LOGIC_FUNCTION:
return 'Logic Functions (custom tools)';
case ToolCategory.VIEW_FIELD:
return 'View Field Tools (manage view columns)';
case ToolCategory.NAVIGATION_MENU_ITEM:
return 'Navigation Menu Item Tools (sidebar entries, folders, and user favorites)';
case ToolCategory.WEBHOOK:
return 'Webhook Tools (outgoing webhooks)';
default:
return assertUnreachable(category);
}
@@ -12,6 +12,7 @@ import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigatio
import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { NavigationMenuItemDeletionService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { NavigationMenuItemToolWorkspaceService } from 'src/engine/metadata-modules/navigation-menu-item/tools/services/navigation-menu-item-tool.workspace-service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -35,10 +36,12 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
NavigationMenuItemRecordIdentifierService,
NavigationMenuItemGraphqlApiExceptionInterceptor,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
NavigationMenuItemToolWorkspaceService,
],
exports: [
NavigationMenuItemService,
NavigationMenuItemRecordIdentifierService,
NavigationMenuItemToolWorkspaceService,
],
})
export class NavigationMenuItemModule {}
@@ -44,9 +44,17 @@ export class NavigationMenuItemService {
async findAll({
workspaceId,
userWorkspaceId,
scope = 'all',
folderId,
type,
limit,
}: {
workspaceId: string;
userWorkspaceId?: string;
scope?: 'all' | 'workspace' | 'user';
folderId?: string;
type?: NavigationMenuItemType;
limit?: number;
}): Promise<NavigationMenuItemDTO[]> {
const { flatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
@@ -56,15 +64,33 @@ export class NavigationMenuItemService {
},
);
return Object.values(flatNavigationMenuItemMaps.byUniversalIdentifier)
.filter(
(item): item is NonNullable<typeof item> =>
isDefined(item) &&
(!isDefined(item.userWorkspaceId) ||
item.userWorkspaceId === userWorkspaceId),
)
.sort((a, b) => a.position - b.position)
.map(fromFlatNavigationMenuItemToNavigationMenuItemDto);
const filtered = Object.values(
flatNavigationMenuItemMaps.byUniversalIdentifier,
)
.filter((item): item is NonNullable<typeof item> => {
if (!isDefined(item)) return false;
const itemIsUserScoped = isDefined(item.userWorkspaceId);
// Default scope returns workspace items + the caller's own user items.
if (scope === 'all') {
return !itemIsUserScoped || item.userWorkspaceId === userWorkspaceId;
}
if (scope === 'workspace') {
return !itemIsUserScoped;
}
// scope === 'user'
return itemIsUserScoped && item.userWorkspaceId === userWorkspaceId;
})
.filter((item) => !isDefined(folderId) || item.folderId === folderId)
.filter((item) => !isDefined(type) || item.type === type)
.sort((a, b) => a.position - b.position);
const bounded = isDefined(limit) ? filtered.slice(0, limit) : filtered;
return bounded.map(fromFlatNavigationMenuItemToNavigationMenuItemDto);
}
async findById({
@@ -0,0 +1,197 @@
import { z } from 'zod';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
import { navigationMenuItemScopeSchema } from 'src/engine/metadata-modules/navigation-menu-item/tools/schemas/navigation-menu-item-scope.schema';
import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-context.type';
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
const commonOptionalFields = {
icon: z
.string()
.optional()
.describe('Icon identifier (e.g. "IconStar", "IconFolder")'),
color: z.string().optional().describe('Optional hex colour'),
position: z
.number()
.optional()
.describe('Position among siblings; defaults to the end.'),
folderId: z
.string()
.uuid()
.optional()
.describe('Parent folder id, if the item should live inside a folder.'),
};
const requiredNameField = z
.string()
.trim()
.min(1)
.describe('Label shown in the sidebar.');
const derivedNameField = z
.string()
.trim()
.min(1)
.optional()
.describe(
"Optional custom label. If omitted, the sidebar shows the target's own name (object's plural label / view name / record identifier). Only pass this if the user explicitly wants a different label.",
);
const createNavigationMenuItemSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal(NavigationMenuItemType.FOLDER),
scope: navigationMenuItemScopeSchema,
name: requiredNameField,
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.LINK),
scope: navigationMenuItemScopeSchema,
name: requiredNameField,
link: z.string().url().describe('Target URL'),
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.OBJECT),
scope: navigationMenuItemScopeSchema,
targetObjectMetadataId: z
.string()
.uuid()
.describe('Id of the object to pin'),
name: derivedNameField,
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.VIEW),
scope: navigationMenuItemScopeSchema,
viewId: z.string().uuid().describe('Id of the view to pin'),
name: derivedNameField,
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.RECORD),
scope: navigationMenuItemScopeSchema,
targetRecordId: z.string().uuid().describe('Id of the record to pin'),
targetObjectMetadataId: z
.string()
.uuid()
.describe("Id of the record's object metadata"),
name: derivedNameField,
...commonOptionalFields,
}),
z.object({
type: z.literal(NavigationMenuItemType.PAGE_LAYOUT),
scope: navigationMenuItemScopeSchema,
pageLayoutId: z.string().uuid().describe('Id of the page layout to pin'),
name: requiredNameField,
...commonOptionalFields,
}),
]);
type CreateNavigationMenuItemParams = z.infer<
typeof createNavigationMenuItemSchema
>;
const toServiceInput = (
params: CreateNavigationMenuItemParams,
userWorkspaceId: string | undefined,
): CreateNavigationMenuItemInput => {
const resolvedUserWorkspaceId =
params.scope === 'user' ? userWorkspaceId : undefined;
const base = {
type: params.type as NavigationMenuItemType,
userWorkspaceId: resolvedUserWorkspaceId,
icon: params.icon,
color: params.color,
position: params.position,
folderId: params.folderId,
};
switch (params.type) {
case NavigationMenuItemType.FOLDER:
return { ...base, name: params.name };
case NavigationMenuItemType.LINK:
return { ...base, name: params.name, link: params.link };
case NavigationMenuItemType.OBJECT:
return {
...base,
name: params.name,
targetObjectMetadataId: params.targetObjectMetadataId,
};
case NavigationMenuItemType.VIEW:
return { ...base, name: params.name, viewId: params.viewId };
case NavigationMenuItemType.RECORD:
return {
...base,
name: params.name,
targetRecordId: params.targetRecordId,
targetObjectMetadataId: params.targetObjectMetadataId,
};
case NavigationMenuItemType.PAGE_LAYOUT:
return {
...base,
name: params.name,
pageLayoutId: params.pageLayoutId,
};
}
};
export const createCreateNavigationMenuItemTool = (
deps: Pick<NavigationMenuItemToolDependencies, 'navigationMenuItemService'>,
context: NavigationMenuItemToolContext,
) => ({
name: 'create_navigation_menu_item' as const,
description: `Create a navigation menu item. With scope='user' it becomes a personal favorite for the current user; with scope='workspace' it is shared with everyone (requires LAYOUTS permission).
Type chooses the variant:
- FOLDER: a group to nest other items into (name required).
- LINK: an external URL pinned in the sidebar (name + link required).
- OBJECT: pins an object's standard view (label auto-derived from the object's plural name; only pass 'name' if the user wants a custom label).
- VIEW: pins a saved view (label auto-derived from the view's name; only pass 'name' for a custom label).
- RECORD: pins a single record (label auto-derived from the record's identifier; only pass 'name' for a custom label).
- PAGE_LAYOUT: pins a page layout, e.g. a dashboard (name required — no auto-derivation).
Note: creating a new custom object via create_object_metadata already auto-creates an OBJECT navigation menu item — do not double-create.`,
inputSchema: createNavigationMenuItemSchema,
execute: async (parameters: CreateNavigationMenuItemParams) => {
try {
if (parameters.scope === 'user' && !context.userWorkspaceId) {
return {
success: false,
message:
'Cannot create a user-scoped favorite without an authenticated user context.',
error: 'missing_user_workspace_id',
};
}
const created = await deps.navigationMenuItemService.create({
input: toServiceInput(parameters, context.userWorkspaceId),
workspaceId: context.workspaceId,
authUserWorkspaceId: context.userWorkspaceId,
});
return {
success: true,
message: `Navigation menu item ${created.id} (${created.type}) created`,
result: {
id: created.id,
type: created.type,
name: created.name,
scope: created.userWorkspaceId ? 'user' : 'workspace',
folderId: created.folderId,
position: created.position,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to create navigation menu item: ${message}`,
error: message,
};
}
},
});
@@ -0,0 +1,48 @@
import { z } from 'zod';
import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-context.type';
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
const deleteNavigationMenuItemSchema = z.object({
id: z.string().uuid().describe('Id of the navigation menu item to delete'),
});
type DeleteNavigationMenuItemParams = z.infer<
typeof deleteNavigationMenuItemSchema
>;
export const createDeleteNavigationMenuItemTool = (
deps: Pick<NavigationMenuItemToolDependencies, 'navigationMenuItemService'>,
context: NavigationMenuItemToolContext,
) => ({
name: 'delete_navigation_menu_item' as const,
description: `Delete a navigation menu item. Deleting a folder also deletes everything inside it.`,
inputSchema: deleteNavigationMenuItemSchema,
execute: async (parameters: DeleteNavigationMenuItemParams) => {
try {
const deleted = await deps.navigationMenuItemService.delete({
id: parameters.id,
workspaceId: context.workspaceId,
authUserWorkspaceId: context.userWorkspaceId,
});
return {
success: true,
message: `Navigation menu item ${deleted.id} deleted`,
result: {
deletedId: deleted.id,
type: deleted.type,
name: deleted.name,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to delete navigation menu item: ${message}`,
error: message,
};
}
},
});
@@ -0,0 +1,92 @@
import { z } from 'zod';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { navigationMenuItemTypeSchema } from 'src/engine/metadata-modules/navigation-menu-item/tools/schemas/navigation-menu-item-type.schema';
import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-context.type';
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
const listNavigationMenuItemsSchema = z.object({
scope: z
.enum(['workspace', 'user', 'all'])
.optional()
.default('all')
.describe(
"'workspace' = shared navigation, 'user' = current user's favorites, 'all' = both merged (default).",
),
folderId: z
.string()
.uuid()
.optional()
.describe('Only return items inside this folder.'),
type: navigationMenuItemTypeSchema
.optional()
.describe(
'Filter by item type (FOLDER, LINK, OBJECT, VIEW, RECORD, PAGE_LAYOUT).',
),
limit: z
.number()
.int()
.min(1)
.max(200)
.optional()
.default(100)
.describe('Max number of items to return.'),
});
type ListNavigationMenuItemsParams = z.infer<
typeof listNavigationMenuItemsSchema
>;
export const createListNavigationMenuItemsTool = (
deps: Pick<NavigationMenuItemToolDependencies, 'navigationMenuItemService'>,
context: NavigationMenuItemToolContext,
) => ({
name: 'list_navigation_menu_items' as const,
description: `List navigation menu items (shared workspace navigation and/or the current user's personal favorites). Returns items sorted by position.`,
inputSchema: listNavigationMenuItemsSchema,
execute: async (parameters: ListNavigationMenuItemsParams) => {
try {
const items = await deps.navigationMenuItemService.findAll({
workspaceId: context.workspaceId,
userWorkspaceId: context.userWorkspaceId,
scope: parameters.scope,
folderId: parameters.folderId,
type: parameters.type as NavigationMenuItemType | undefined,
limit: parameters.limit,
});
return {
success: true,
message: `Found ${items.length} navigation menu item(s)`,
result: {
items: items.map((item) => ({
id: item.id,
type: item.type,
name: item.name,
scope: isDefined(item.userWorkspaceId) ? 'user' : 'workspace',
folderId: item.folderId,
position: item.position,
icon: item.icon,
color: item.color,
link: item.link,
targetObjectMetadataId: item.targetObjectMetadataId,
targetRecordId: item.targetRecordId,
viewId: item.viewId,
pageLayoutId: item.pageLayoutId,
})),
count: items.length,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to list navigation menu items: ${message}`,
error: message,
};
}
},
});
@@ -0,0 +1,9 @@
import { z } from 'zod';
export const navigationMenuItemScopeSchema = z
.enum(['workspace', 'user'])
.describe(
"'user' creates a personal favorite, visible only to the current user. " +
"'workspace' creates a shared navigation menu item visible to everyone (requires the LAYOUTS permission). " +
'Twenty has no separate Favorites concept — favorites are just navigation menu items with scope=user.',
);
@@ -0,0 +1,11 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { z } from 'zod';
export const navigationMenuItemTypeSchema = z.enum([
NavigationMenuItemType.FOLDER,
NavigationMenuItemType.LINK,
NavigationMenuItemType.OBJECT,
NavigationMenuItemType.VIEW,
NavigationMenuItemType.RECORD,
NavigationMenuItemType.PAGE_LAYOUT,
]);
@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { createCreateNavigationMenuItemTool } from 'src/engine/metadata-modules/navigation-menu-item/tools/create-navigation-menu-item.tool';
import { createDeleteNavigationMenuItemTool } from 'src/engine/metadata-modules/navigation-menu-item/tools/delete-navigation-menu-item.tool';
import { createListNavigationMenuItemsTool } from 'src/engine/metadata-modules/navigation-menu-item/tools/list-navigation-menu-items.tool';
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
import { createUpdateNavigationMenuItemTool } from 'src/engine/metadata-modules/navigation-menu-item/tools/update-navigation-menu-item.tool';
@Injectable()
export class NavigationMenuItemToolWorkspaceService {
private readonly deps: NavigationMenuItemToolDependencies;
constructor(navigationMenuItemService: NavigationMenuItemService) {
this.deps = { navigationMenuItemService };
}
generateNavigationMenuItemTools(
workspaceId: string,
userWorkspaceId?: string,
): ToolSet {
const context = { workspaceId, userWorkspaceId };
const listNavigationMenuItems = createListNavigationMenuItemsTool(
this.deps,
context,
);
const createNavigationMenuItem = createCreateNavigationMenuItemTool(
this.deps,
context,
);
const updateNavigationMenuItem = createUpdateNavigationMenuItemTool(
this.deps,
context,
);
const deleteNavigationMenuItem = createDeleteNavigationMenuItemTool(
this.deps,
context,
);
return {
[listNavigationMenuItems.name]: listNavigationMenuItems,
[createNavigationMenuItem.name]: createNavigationMenuItem,
[updateNavigationMenuItem.name]: updateNavigationMenuItem,
[deleteNavigationMenuItem.name]: deleteNavigationMenuItem,
};
}
}
@@ -0,0 +1,4 @@
export type NavigationMenuItemToolContext = {
workspaceId: string;
userWorkspaceId?: string;
};
@@ -0,0 +1,5 @@
import type { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
export type NavigationMenuItemToolDependencies = {
navigationMenuItemService: NavigationMenuItemService;
};
@@ -0,0 +1,93 @@
import { z } from 'zod';
import { type UpdateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-context.type';
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
const updateNavigationMenuItemSchema = z.object({
id: z.string().uuid().describe('Id of the navigation menu item to update'),
name: z
.string()
.trim()
.min(1)
.optional()
.describe(
"New display name. For OBJECT/VIEW/RECORD items the sidebar normally shows the target entity's own name — only set this if the user wants a custom label.",
),
icon: z.string().optional().describe('New icon identifier'),
color: z.string().optional().describe('New hex colour'),
position: z.number().optional().describe('New position among siblings'),
folderId: z
.string()
.uuid()
.nullable()
.optional()
.describe(
'Move into a different folder. Pass null to move to the top level.',
),
link: z
.string()
.url()
.optional()
.describe('New URL (only meaningful for LINK items)'),
pageLayoutId: z
.string()
.uuid()
.optional()
.describe('New page layout id (only meaningful for PAGE_LAYOUT items)'),
});
type UpdateNavigationMenuItemParams = z.infer<
typeof updateNavigationMenuItemSchema
>;
export const createUpdateNavigationMenuItemTool = (
deps: Pick<NavigationMenuItemToolDependencies, 'navigationMenuItemService'>,
context: NavigationMenuItemToolContext,
) => ({
name: 'update_navigation_menu_item' as const,
description: `Update a navigation menu item (rename, recolor, move between folders, reorder). Type and target ids are immutable — delete and recreate to convert one variant into another.`,
inputSchema: updateNavigationMenuItemSchema,
execute: async (parameters: UpdateNavigationMenuItemParams) => {
try {
const update: Partial<UpdateNavigationMenuItemInput> = {};
if (parameters.name !== undefined) update.name = parameters.name;
if (parameters.icon !== undefined) update.icon = parameters.icon;
if (parameters.color !== undefined) update.color = parameters.color;
if (parameters.position !== undefined)
update.position = parameters.position;
if (parameters.folderId !== undefined)
update.folderId = parameters.folderId;
if (parameters.link !== undefined) update.link = parameters.link;
if (parameters.pageLayoutId !== undefined)
update.pageLayoutId = parameters.pageLayoutId;
const updated = await deps.navigationMenuItemService.update({
input: { id: parameters.id, ...update },
workspaceId: context.workspaceId,
authUserWorkspaceId: context.userWorkspaceId,
});
return {
success: true,
message: `Navigation menu item ${updated.id} updated`,
result: {
id: updated.id,
type: updated.type,
name: updated.name,
folderId: updated.folderId,
position: updated.position,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to update navigation menu item: ${message}`,
error: message,
};
}
},
});
@@ -0,0 +1,75 @@
import { z } from 'zod';
import { webhookOperationSchema } from 'src/engine/metadata-modules/webhook/tools/schemas/webhook-operation.schema';
import { type WebhookToolContext } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-context.type';
import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-dependencies.type';
import { compileWebhookOperations } from 'src/engine/metadata-modules/webhook/tools/utils/compile-webhook-operations.util';
const createWebhookSchema = z.object({
targetUrl: z
.string()
.url()
.describe('Absolute URL the webhook payload should be POSTed to'),
operations: webhookOperationSchema,
description: z
.string()
.optional()
.describe('Optional human description for the webhook'),
secret: z
.string()
.optional()
.describe(
'Optional shared secret used to sign payloads. A secret is generated if omitted.',
),
});
type CreateWebhookParams = z.infer<typeof createWebhookSchema>;
export const createCreateWebhookTool = (
deps: Pick<WebhookToolDependencies, 'webhookService'>,
context: WebhookToolContext,
) => ({
name: 'create_webhook' as const,
description: `Register a new outgoing webhook for this workspace.
Operations are structured entries discriminated by 'kind':
- {kind:'record', object:'person', event:'created'} → fires when a person is created (compiles to 'person.created').
- {kind:'record', object:'*', event:'*'} → fires on every record event.
- {kind:'metadata', metadataName:'workflow', operation:'updated'} → fires when a workflow definition is updated (compiles to 'metadata.workflow.updated').
- {kind:'metadata', metadataName:'*', operation:'*'} → fires on every metadata change.
Mix as needed: pass one array containing both record and metadata operations.`,
inputSchema: createWebhookSchema,
execute: async (parameters: CreateWebhookParams) => {
try {
const webhook = await deps.webhookService.create(
{
targetUrl: parameters.targetUrl,
operations: compileWebhookOperations(parameters.operations),
description: parameters.description,
secret: parameters.secret,
},
context.workspaceId,
);
return {
success: true,
message: `Webhook created for ${webhook.targetUrl}`,
result: {
id: webhook.id,
targetUrl: webhook.targetUrl,
operations: webhook.operations,
description: webhook.description,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to create webhook: ${message}`,
error: message,
};
}
},
});
@@ -0,0 +1,41 @@
import { z } from 'zod';
import { type WebhookToolContext } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-context.type';
import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-dependencies.type';
const deleteWebhookSchema = z.object({
id: z.string().uuid().describe('The id of the webhook to delete'),
});
type DeleteWebhookParams = z.infer<typeof deleteWebhookSchema>;
export const createDeleteWebhookTool = (
deps: Pick<WebhookToolDependencies, 'webhookService'>,
context: WebhookToolContext,
) => ({
name: 'delete_webhook' as const,
description: `Delete a webhook by id. Use list_webhooks first if you don't know the id.`,
inputSchema: deleteWebhookSchema,
execute: async (parameters: DeleteWebhookParams) => {
try {
const webhook = await deps.webhookService.delete(
parameters.id,
context.workspaceId,
);
return {
success: true,
message: `Webhook ${webhook.id} deleted`,
result: { deletedWebhookId: webhook.id, targetUrl: webhook.targetUrl },
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to delete webhook: ${message}`,
error: message,
};
}
},
});
@@ -0,0 +1,44 @@
import { z } from 'zod';
import { type WebhookToolContext } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-context.type';
import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-dependencies.type';
const listWebhooksSchema = z.object({});
export const createListWebhooksTool = (
deps: Pick<WebhookToolDependencies, 'webhookService'>,
context: WebhookToolContext,
) => ({
name: 'list_webhooks' as const,
description: `List every webhook registered in the workspace. Returns id, targetUrl, operations (e.g. ['person.created','company.updated']), description and timestamps.`,
inputSchema: listWebhooksSchema,
execute: async () => {
try {
const webhooks = await deps.webhookService.findAll(context.workspaceId);
return {
success: true,
message: `Found ${webhooks.length} webhook(s)`,
result: {
webhooks: webhooks.map((webhook) => ({
id: webhook.id,
targetUrl: webhook.targetUrl,
operations: webhook.operations,
description: webhook.description,
createdAt: webhook.createdAt,
updatedAt: webhook.updatedAt,
})),
count: webhooks.length,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to list webhooks: ${message}`,
error: message,
};
}
},
});
@@ -0,0 +1,45 @@
import { z } from 'zod';
const recordOperationSchema = z.object({
kind: z
.literal('record')
.describe("Record event ('<objectNameSingular>.<event>')"),
object: z
.string()
.min(1)
.describe(
"Object name singular (e.g. 'person', 'company', 'task'), or '*' for all objects.",
),
event: z
.enum(['created', 'updated', 'deleted', '*'])
.describe("Event kind. Use '*' to match every event for the given object."),
});
const metadataOperationSchema = z.object({
kind: z
.literal('metadata')
.describe(
"Metadata event ('metadata.<metadataName>.<operation>') — fires on changes to objects, fields, views, workflows, etc.",
),
metadataName: z
.string()
.min(1)
.describe(
"Metadata name (e.g. 'object', 'field', 'view', 'workflow'), or '*' for all.",
),
operation: z
.enum(['created', 'updated', 'deleted', '*'])
.describe("Operation kind. Use '*' to match every operation."),
});
export const webhookOperationSchema = z
.array(
z.discriminatedUnion('kind', [
recordOperationSchema,
metadataOperationSchema,
]),
)
.min(1)
.describe(
"Events that trigger the webhook. Record events compile to '<object>.<event>' (e.g. 'person.created'). Metadata events compile to 'metadata.<metadataName>.<operation>' (e.g. 'metadata.workflow.updated'). Use [{kind:'record',object:'*',event:'*'}] to subscribe to all record events.",
);
@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { WebhookService } from 'src/engine/metadata-modules/webhook/webhook.service';
import { createCreateWebhookTool } from 'src/engine/metadata-modules/webhook/tools/create-webhook.tool';
import { createDeleteWebhookTool } from 'src/engine/metadata-modules/webhook/tools/delete-webhook.tool';
import { createListWebhooksTool } from 'src/engine/metadata-modules/webhook/tools/list-webhooks.tool';
import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-dependencies.type';
import { createUpdateWebhookTool } from 'src/engine/metadata-modules/webhook/tools/update-webhook.tool';
@Injectable()
export class WebhookToolWorkspaceService {
private readonly deps: WebhookToolDependencies;
constructor(webhookService: WebhookService) {
this.deps = { webhookService };
}
generateWebhookTools(workspaceId: string): ToolSet {
const context = { workspaceId };
const listWebhooks = createListWebhooksTool(this.deps, context);
const createWebhook = createCreateWebhookTool(this.deps, context);
const updateWebhook = createUpdateWebhookTool(this.deps, context);
const deleteWebhook = createDeleteWebhookTool(this.deps, context);
return {
[listWebhooks.name]: listWebhooks,
[createWebhook.name]: createWebhook,
[updateWebhook.name]: updateWebhook,
[deleteWebhook.name]: deleteWebhook,
};
}
}
@@ -0,0 +1,3 @@
export type WebhookToolContext = {
workspaceId: string;
};
@@ -0,0 +1,5 @@
import type { WebhookService } from 'src/engine/metadata-modules/webhook/webhook.service';
export type WebhookToolDependencies = {
webhookService: WebhookService;
};
@@ -0,0 +1,78 @@
import { z } from 'zod';
import { webhookOperationSchema } from 'src/engine/metadata-modules/webhook/tools/schemas/webhook-operation.schema';
import { type WebhookToolContext } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-context.type';
import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-dependencies.type';
import { compileWebhookOperations } from 'src/engine/metadata-modules/webhook/tools/utils/compile-webhook-operations.util';
const updateWebhookSchema = z.object({
id: z.string().uuid().describe('The id of the webhook to update'),
targetUrl: z
.string()
.url()
.optional()
.describe('New target URL. Leave unset to keep the current value.'),
operations: webhookOperationSchema
.optional()
.describe('Replaces the operations list. Leave unset to keep current.'),
description: z.string().optional(),
secret: z.string().optional(),
});
type UpdateWebhookParams = z.infer<typeof updateWebhookSchema>;
export const createUpdateWebhookTool = (
deps: Pick<WebhookToolDependencies, 'webhookService'>,
context: WebhookToolContext,
) => ({
name: 'update_webhook' as const,
description: `Update an existing webhook. Only the fields you pass are modified; everything else is preserved.`,
inputSchema: updateWebhookSchema,
execute: async (parameters: UpdateWebhookParams) => {
try {
const update: {
targetUrl?: string;
operations?: string[];
description?: string;
secret?: string;
} = {};
if (parameters.targetUrl !== undefined) {
update.targetUrl = parameters.targetUrl;
}
if (parameters.operations !== undefined) {
update.operations = compileWebhookOperations(parameters.operations);
}
if (parameters.description !== undefined) {
update.description = parameters.description;
}
if (parameters.secret !== undefined) {
update.secret = parameters.secret;
}
const webhook = await deps.webhookService.update(
{ id: parameters.id, update },
context.workspaceId,
);
return {
success: true,
message: `Webhook ${webhook.id} updated`,
result: {
id: webhook.id,
targetUrl: webhook.targetUrl,
operations: webhook.operations,
description: webhook.description,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to update webhook: ${message}`,
error: message,
};
}
},
});
@@ -0,0 +1,14 @@
import { type z } from 'zod';
import { webhookOperationSchema } from 'src/engine/metadata-modules/webhook/tools/schemas/webhook-operation.schema';
export const compileWebhookOperations = (
operations: z.infer<typeof webhookOperationSchema>,
): string[] =>
operations.map((operation) => {
if (operation.kind === 'record') {
return `${operation.object}.${operation.event}`;
}
return `metadata.${operation.metadataName}.${operation.operation}`;
});
@@ -10,6 +10,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
import { WebhookController } from 'src/engine/metadata-modules/webhook/controllers/webhook.controller';
import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity';
import { WebhookGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/webhook/interceptors/webhook-graphql-api-exception.interceptor';
import { WebhookToolWorkspaceService } from 'src/engine/metadata-modules/webhook/tools/services/webhook-tool.workspace-service';
import { WebhookResolver } from 'src/engine/metadata-modules/webhook/webhook.resolver';
import { WebhookService } from 'src/engine/metadata-modules/webhook/webhook.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -33,7 +34,8 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WebhookResolver,
WebhookGraphqlApiExceptionInterceptor,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
WebhookToolWorkspaceService,
],
exports: [WebhookService],
exports: [WebhookService, WebhookToolWorkspaceService],
})
export class WebhookModule {}