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:
+75
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
+41
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
+44
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
+45
@@ -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.",
|
||||
);
|
||||
+35
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export type WebhookToolContext = {
|
||||
workspaceId: string;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import type { WebhookService } from 'src/engine/metadata-modules/webhook/webhook.service';
|
||||
|
||||
export type WebhookToolDependencies = {
|
||||
webhookService: WebhookService;
|
||||
};
|
||||
+78
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
+14
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user