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:
@@ -2476,6 +2476,18 @@ type ImapSmtpCaldavConnectionSuccess {
|
||||
connectedAccountId: String!
|
||||
}
|
||||
|
||||
type Webhook {
|
||||
id: UUID!
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String!
|
||||
applicationId: UUID!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
deletedAt: DateTime
|
||||
}
|
||||
|
||||
type ToolIndexEntry {
|
||||
name: String!
|
||||
description: String!
|
||||
@@ -2902,18 +2914,6 @@ type MinimalMetadata {
|
||||
collectionHashes: [CollectionHash!]!
|
||||
}
|
||||
|
||||
type Webhook {
|
||||
id: UUID!
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String!
|
||||
applicationId: UUID!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
deletedAt: DateTime
|
||||
}
|
||||
|
||||
type Query {
|
||||
navigationMenuItems: [NavigationMenuItem!]!
|
||||
navigationMenuItem(id: UUID!): NavigationMenuItem
|
||||
@@ -2982,6 +2982,8 @@ type Query {
|
||||
getRoles: [Role!]!
|
||||
getToolIndex: [ToolIndexEntry!]!
|
||||
getToolInputSchema(toolName: String!): JSON
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
field(
|
||||
"""The id of the record to find."""
|
||||
id: UUID!
|
||||
@@ -2999,8 +3001,6 @@ type Query {
|
||||
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
minimalMetadata: MinimalMetadata!
|
||||
chatThreads: [AgentChatThread!]!
|
||||
chatThread(id: UUID!): AgentChatThread!
|
||||
@@ -3222,6 +3222,9 @@ type Mutation {
|
||||
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
|
||||
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
|
||||
removeRoleFromAgent(agentId: UUID!): Boolean!
|
||||
createWebhook(input: CreateWebhookInput!): Webhook!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
deleteWebhook(id: UUID!): Webhook!
|
||||
createOneField(input: CreateOneFieldMetadataInput!): Field!
|
||||
updateOneField(input: UpdateOneFieldMetadataInput!): Field!
|
||||
deleteOneField(input: DeleteOneFieldInput!): Field!
|
||||
@@ -3238,9 +3241,6 @@ type Mutation {
|
||||
deleteEmailGroupChannel(id: UUID!): MessageChannel!
|
||||
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
|
||||
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
|
||||
createWebhook(input: CreateWebhookInput!): Webhook!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
deleteWebhook(id: UUID!): Webhook!
|
||||
createChatThread: AgentChatThread!
|
||||
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
|
||||
stopAgentChatStream(threadId: UUID!): Boolean!
|
||||
@@ -4047,6 +4047,29 @@ input RowLevelPermissionPredicateGroupInput {
|
||||
positionInRowLevelPermissionPredicateGroup: Float
|
||||
}
|
||||
|
||||
input CreateWebhookInput {
|
||||
id: UUID
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input UpdateWebhookInput {
|
||||
"""The id of the webhook to update"""
|
||||
id: UUID!
|
||||
|
||||
"""The webhook fields to update"""
|
||||
update: UpdateWebhookInputUpdates!
|
||||
}
|
||||
|
||||
input UpdateWebhookInputUpdates {
|
||||
targetUrl: String
|
||||
operations: [String!]
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input CreateOneFieldMetadataInput {
|
||||
"""The record to create"""
|
||||
field: CreateFieldInput!
|
||||
@@ -4184,29 +4207,6 @@ input UpdateCalendarChannelInputUpdates {
|
||||
isSyncEnabled: Boolean
|
||||
}
|
||||
|
||||
input CreateWebhookInput {
|
||||
id: UUID
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input UpdateWebhookInput {
|
||||
"""The id of the webhook to update"""
|
||||
id: UUID!
|
||||
|
||||
"""The webhook fields to update"""
|
||||
update: UpdateWebhookInputUpdates!
|
||||
}
|
||||
|
||||
input UpdateWebhookInputUpdates {
|
||||
targetUrl: String
|
||||
operations: [String!]
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input FileAttachmentInput {
|
||||
id: UUID!
|
||||
filename: String!
|
||||
|
||||
Reference in New Issue
Block a user