Restructure the Gmail-only "apps/toolsets" POC so adding a second app is a one-file change. Each integration ships as an `AppDefinition` in `@n8n/agents/src/toolsets/registry/`; backend and frontend consume the registry through the same flat re-exports off `@n8n/agents`. - New `@n8n/agents/toolsets/` module: `AppDefinition` + `OperationEntry` types, `buildOperationsFromDescription` (single source of truth for the registry walk + scope-based status classification), `buildManifest` (string/function/default-deriver resolution), and `APP_REGISTRY` with Gmail as the first entry. - Generic `buildAppToolset` factory in cli replaces the Gmail-specific `buildGmailToolset`. `agents.service.attachAppToolsets` now resolves app kinds via `findAppDefinition` and skips unknown kinds with a warn log instead of rejecting at save time. - New `GET /credentials/:credentialId/oauth-scopes` endpoint + `CredentialsService.getOAuthGrantedScopes` helper. Reads `oauthTokenData.scope` (post-grant) with a fallback to declared `scope` (pre-grant). Distinct from the existing RBAC `getCredentialScopes`. - New `useCredentialScopes` FE composable feeds the App config modal so per-operation status badges classify against the credential's actually-granted scopes. - `AgentJsonAppRef.kind` widened from the `'gmail'` literal to plain string. Forward-compat: a config saved with an unknown kind loads cleanly. - Editor-ui: `AgentAppsModal` rebuilt on `Modal.vue` + `NodeCredentials` (drops the hand-rolled backdrop and raw select). `AgentAppConfigModal`, `AgentAppsListPanel`, and `AgentChatToolSteps` resolve icon, label, and dispatcher names from the registry. - Drop the Gmail-only `gmail-toolset.ts` and `gmailOperations.ts`. Tests: 10 new in `@n8n/agents/toolsets`, 6 in `cli/src/modules/agents/toolsets`, 4 in `cli/src/credentials`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.6 KiB
Toolsets POC — notes
Gmail-only toolset POC for @n8n/agents.
Architecture choices
Schema format
Reuse the existing $fromAI(...) zod machinery. The toolset uses
generateZodSchema from from-ai-parse-utils.ts
directly: per filtered INodeProperties, build a FromAIArgument
(name → key, n8n type → fromAI type, displayName/description → description),
call generateZodSchema, assemble z.object({...}). No handrolled JSON
Schema converter, no $fromAI(...) placeholder strings — we own the
parameter object end-to-end.
Invocation
Direct call to createNodeAsTool / makeHandleToolInvocation from outside
an executing node turned out to be hard (they close over engine-internal
state). Instead the toolset reuses the existing
EphemeralNodeExecutor.executeInline
that the node-tool chain (run_node_tool) already uses. Same path: build
{ nodeType, nodeTypeVersion, nodeParameters: { resource, operation, ...args }, credentialDetails, inputData, projectId },
hand to the executor, get back a NodeExecutionResult. Engine handles
context construction, credential decryption, retries.
This is a free win — the CLI already had the exact primitive we needed. The original "construct a one-node Workflow + WorkflowRunner.run()" plan is unnecessary.
Agent integration shape
Auto-load (Option α). Each attached app surfaces as a single dispatcher
tool whose input is { action, name?, args? } where action ∈
list_operations | describe_operation | invoke_operation. No discovery
load_toolset() step. Wired in
agents.service.ts:attachAppToolsets.
The tool's outer schema is loose by design — per-operation schemas only
materialize after describe_operation. The dispatcher validates args
against the per-operation zod schema inside invoke_operation to give
proper errors if the model passes the wrong shape.
Persistence shape
AgentJsonAppRef + Zod schema
in agent-json-config.ts:
{ kind: 'gmail'; credentialId: string; credentialName: string }
Scope gating
No-op for POC. Gmail credential's six static scopes cover all 26 operations.
Files added / changed
New
packages/cli/src/modules/agents/toolsets/gmail-toolset.ts— operation registry + dispatcher tool factory.packages/frontend/editor-ui/src/features/agents/components/AgentAppsListPanel.vue— Apps section in the agent builder.packages/frontend/editor-ui/src/features/agents/components/AgentAppsModal.vue— Add App modal.
Modified
packages/@n8n/api-types/src/agents.ts—AgentJsonAppRef+appsonAgentJsonConfig.packages/cli/src/modules/agents/json-config/agent-json-config.ts— Zod schema mirror.packages/cli/src/modules/agents/agents.service.ts—attachAppToolsetsinjection ininjectRuntimeDependencies. Constructor gainsNodeTypes.packages/cli/src/modules/agents/__tests__/{agents-service-reconstruct-gating,agents-service-sync,agents.service}.test.ts— extramock<NodeTypes>()arg in constructor calls.packages/frontend/editor-ui/src/features/agents/types.ts— re-exportAgentJsonAppRef.packages/frontend/editor-ui/src/features/agents/constants.ts—AGENT_APPS_MODAL_KEY.packages/frontend/editor-ui/src/features/agents/module.descriptor.ts— register the modal.packages/frontend/editor-ui/src/features/agents/components/AgentConfigTree.vue— insertappsrow abovetools.packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue— section dispatcher + add/remove handlers.
Demo path
- Open the agent builder.
- In the sidebar tree there's a new Apps entry above Tools.
- Click it → "Add app" → modal with Gmail.
- Pick an existing
gmailOAuth2credential → Add. (Inline cred creation is intentionally skipped for the POC — create one in the Credentials view first if none exist.) - Open the agent's chat. Prompt: "list the gmail operations available, then send a test email to me@example.com with subject 'POC' and body 'hi'".
- Agent should call
gmail({ action: 'list_operations' })→ 26 operations returned, thengmail({ action: 'describe_operation', name: 'message:send' })→ schema, thengmail({ action: 'invoke_operation', name: 'message:send', args: {...} })→EphemeralNodeExecutorruns the Gmail node, email lands.
TODO: generalize spots
All marked in code with // TODO: generalize. Inventory:
- [gmail-toolset.ts] Whole module is Gmail-specific. Make it a generic factory taking an
INodeTypeDescription+ a per-node manifest blob. - [gmail-toolset.ts] Hardcoded Gmail manifest text.
- [gmail-toolset.ts]
buildOperationRegistryassumes the n8n convention of a top-levelresource+operationselector. Other layouts (e.g. nodes scoped bymode) need different bucketing. - [gmail-toolset.ts]
nodePropertyTypeToFromAITypedoesn't modelresourceLocatorormultiOptions. They get treated as plain strings/json — agent gets a callable shape but loses richness. - [gmail-toolset.ts]
zodSchemaToJsonis a trivial walker for the POC. Replace withzodToJsonSchemafrom@n8n/agents/utils/zodonce we want full fidelity indescribe_operationoutput. - [agents.ts api-types]
AgentJsonAppRef.kindis'gmail'literal. Will widen to a string union as the catalog grows. - [agent-json-config.ts] Zod schema mirrors the literal — same widening.
- [agents.service.ts attachAppToolsets]
if (app.kind === 'gmail') ...hardcoded. Replace with a registry keyed bykind. - [AgentAppsModal.vue] Single hardcoded Gmail entry. Needs a catalog list + per-app credential type derivation.
- [AgentAppsModal.vue] Inline credential creation skipped. Wire
uiStore.openNewCredential()+ auth-change listener (seeNodeCredentials.vuefor reference). - Scope gating — no-op today. Per-operation
requiredScopes: []slot exists in spirit; needs an authored mapping when a real gating need lands.
Open architectural questions surfaced during build
-
Execution history pollution. Every
invoke_operationcall goes throughEphemeralNodeExecutor.executeInline. I haven't audited whether that creates a visible execution row. If yes, an agent that calls Gmail 50 times during a conversation will pollute the user's execution history. Worth checking before this lands as anything more than a POC — probably want asuppressPersistenceflag, or to use a different execution path for toolset invocations. -
Schema fidelity in
describe_operation. The currentzodSchemaToJsonproduces a thin shape. Some Gmail operations havecollection/fixedCollectionparams that flatten to'object'with no nested structure — the model can technically still pass them through, but won't know the inner shape. Real fix is to plugzodToJsonSchemain. Cheap, just didn't do it for the POC. -
Catalog for
list_operations. Today the tool description string contains the manifest and tells the agent to calllist_operationsfirst. This is two ways to find out the same thing. Decide whether the manifest stays in the tool description (cheap, always seen) or is gated behindlist_operations(saves tokens, costs a turn). -
One tool per app vs. one tool total. With many apps attached, the agent's tool list grows linearly. Once we have 5+ apps it might be worth a single
appstool that takesappNameas the first arg, collapsing the surface. Not urgent for one app.