Skip to content

Agent Internal Tools UI

The Herramientas Internas sub-tab in the agent edit dialog's Herramientas step (step 7) lets operators configure which Operations tools — tasks, projects, and calendar events — an AI agent can invoke during a conversation, and at what permission level.

A companion ToolApprovalCard renders inside the chat view when a tool with needs_approval permission is invoked by the LLM, pausing execution until the user responds.

For the underlying API and domain model, see:


Overview

When an operator opens the Herramientas step, they see a tab switcher with two tabs:

TabDefaultDescription
Servidores MCP✅ YesExisting MCP server workspace — unchanged
Herramientas InternasNoOperations tool permission selectors for the agent

Herramientas Internas Tab — 3-State Selector

Loading states

StateRendered UI
isLoadingAnimated skeleton (data-testid="internal-tools-loading")
isErrorError message with Reintentar button that calls refetch()
Empty catalog (tools.length === 0)Empty-state message (data-testid="internal-tools-empty")
ReadyInternalToolsProviderGroup with 3-state selectors

Provider group component — 3-state selector

InternalToolsProviderGroup renders a labeled group (e.g., "Operaciones") with a 3-state selector per tool. The three states use the same Spanish labels as the rest of the panel (REQ-INTERNAL-TOOLS-2):

Selector labelpermissionStatus valueRuntime effect
Permitir siemprealways_allowTool executes without prompting the user
Pedir aprobaciónneeds_approvalTool pauses; user sees ToolApprovalCard in chat
BloqueadoblockedTool is hidden from the LLM entirely

[screenshot placeholder — 3-state selector in the agent config dialog]

Master row (bulk apply)

The provider group header row includes a master selector that applies the chosen status to all tools in that provider group at once. When clicked, the onChange callback is called with the full tools array updated to the selected status.

The master selector shows no mixed/indeterminate state — it is a direct action button rather than a reflection of current state.

Selector disabled state

All selectors are disabled when a save is in progress (isPending === true). This prevents concurrent mutations and gives the user visual feedback that the change is being persisted.

[screenshot placeholder — disabled selectors during save]

Persistence behavior

Every selector change triggers an immediate bulk PUT — there is no separate Guardar button for this tab. The useUpsertAgentInternalTools mutation sends the new complete tools array to:

PUT /ai/agents/:agentId/tools
{ "tools": [{ "toolName": "...", "permissionStatus": "...", "providerKey": "..." }] }

Optimistic update and rollback

  1. On mutate(), the TanStack Query cache is updated immediately with the new tool list (optimistic UI — zero perceived latency).
  2. If the API call succeeds, the cache is invalidated to sync server state.
  3. If the API call fails, the cache is rolled back to the snapshot captured before the mutation. A Spanish toast error is shown:
No se pudieron guardar las herramientas internas

Component Architecture

CreateAgentStepTools          ← receives agentId prop
└── @base-ui Tabs
    ├── TabsList
    │   ├── "Servidores MCP" trigger
    │   └── "Herramientas Internas" trigger
    ├── TabsContent[mcp-servers]
    │   └── AgentMcpWorkspace         (unchanged)
    └── TabsContent[internal-tools]
        └── AgentInternalToolsWorkspace
            ├── useAgentInternalToolsCatalog(agentId)   ← TanStack Query
            ├── useUpsertAgentInternalTools(agentId)    ← TanStack Mutation
            └── InternalToolsProviderGroup
                ├── master selector (bulk-set all tools)
                └── per-tool 3-state selector (ToggleGroup)

ToolApprovalCard — Chat View

When the LLM invokes a tool that has needs_approval permission and the user has no override for it, the Vercel AI SDK pauses execution and emits a message part with type='tool-invocation' and state='approval-requested'. The panel's chat-view.tsx detects this and renders a ToolApprovalCard. (REQ-CHAT-2, REQ-CHAT-3)

[screenshot placeholder — ToolApprovalCard in the chat view]

Card Content

The card displays:

  1. Tool name — the human-readable identifier (e.g. create_task).
  2. Tool input arguments — a pretty-printed JSON block of the arguments the LLM provided. These are rendered locally for the user's reference and are never sent to the server's audit log (no-PII design, REQ-AUDIT-2).

Action Buttons (Spanish labels)

ButtonpermissionStatusAction
Aprobar una vez(current invocation only)Calls addToolApprovalResponse({ id, approved: true }). No override is persisted. The next invocation of the same tool will prompt again.
Aprobar siempreapprove-alwaysFire-and-forget POST /agents/:agentId/tool-overrides { toolName }, then addToolApprovalResponse({ id, approved: true }). An override row is created; future invocations skip the prompt.
DenegardenyCalls addToolApprovalResponse({ id, approved: false }). The model receives execution-denied.
Decir algo distintodeny with reasonReveals a textarea (see below). On submit calls addToolApprovalResponse({ id, approved: false, reason }).

[screenshot placeholder — ToolApprovalCard with "Decir algo distinto" textarea revealed]

"Decir algo distinto" Textarea

  • Hidden by default; revealed when the user clicks Decir algo distinto.
  • maxLength=2000. The textarea enforces this client-side. The server also rejects reasons longer than 2000 chars (TOOL_APPROVAL_REASON_TOO_LONG).
  • The submit button is disabled until at least one character has been entered.

Approve Always — Race Window

When the user clicks Aprobar siempre, the override POST request and addToolApprovalResponse fire in sequence. There is an accepted race window of approximately 1 RTT: if the override POST has not completed by the time the auto-resume request hits the server, the current invocation may still be handled as an approve-once. The override will be in place for all subsequent invocations. This is acceptable by design.


Data Flow

AgentInternalToolsWorkspace

  ├─ GET /ai/agents/:id/internal-tools/catalog
  │    returns { providerKey, tools[], enabledTools[] }

  └─ On selector change:
       ├─ Compute next tools[] locally (permissionStatus updated)
       ├─ Optimistic cache update
       └─ PUT /ai/agents/:id/tools
            body: { tools: [{ toolName, permissionStatus, providerKey }] }

ChatView (approval-requested part)

  └─ Render ToolApprovalCard
       ├─ "Aprobar una vez"   → addToolApprovalResponse({ approved: true })
       ├─ "Aprobar siempre"   → POST /tool-overrides + addToolApprovalResponse({ approved: true })
       ├─ "Denegar"           → addToolApprovalResponse({ approved: false })
       └─ "Decir algo distinto" → textarea → addToolApprovalResponse({ approved: false, reason })
            └─ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses
                 → auto re-POST /chat with approval response parts

Query Keys

KeyUsed by
['agent-internal-tools', agentId]useAgentInternalToolsCatalog, useUpsertAgentInternalTools

Shared Zod Schemas

The service layer validates API responses against schemas from @repo/schemas:

SchemaPurpose
agentInternalToolCatalogResponseSchemaValidates GET catalog response shape
agentToolUpsertResponseSchemaValidates PUT tools response shape
agentToolOverrideResponseSchemaValidates POST/GET override response shape
agentToolOverrideListResponseSchemaValidates GET overrides list response shape

V1 Constraints

  • Only the operations provider is supported in v1 — the UI renders a single provider group.
  • All 9 tools are shown; operators can individually set each tool's permission level.
  • The tab switcher UI (@base-ui Tabs) replaced the placeholder that previously existed in step 7.