Appearance
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:
| Tab | Default | Description |
|---|---|---|
| Servidores MCP | ✅ Yes | Existing MCP server workspace — unchanged |
| Herramientas Internas | No | Operations tool permission selectors for the agent |
Herramientas Internas Tab — 3-State Selector
Loading states
| State | Rendered UI |
|---|---|
isLoading | Animated skeleton (data-testid="internal-tools-loading") |
isError | Error message with Reintentar button that calls refetch() |
Empty catalog (tools.length === 0) | Empty-state message (data-testid="internal-tools-empty") |
| Ready | InternalToolsProviderGroup 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 label | permissionStatus value | Runtime effect |
|---|---|---|
| Permitir siempre | always_allow | Tool executes without prompting the user |
| Pedir aprobación | needs_approval | Tool pauses; user sees ToolApprovalCard in chat |
| Bloqueado | blocked | Tool 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
- On
mutate(), the TanStack Query cache is updated immediately with the new tool list (optimistic UI — zero perceived latency). - If the API call succeeds, the cache is invalidated to sync server state.
- 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 internasComponent 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:
- Tool name — the human-readable identifier (e.g.
create_task). - 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)
| Button | permissionStatus | Action |
|---|---|---|
| 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 siempre | approve-always | Fire-and-forget POST /agents/:agentId/tool-overrides { toolName }, then addToolApprovalResponse({ id, approved: true }). An override row is created; future invocations skip the prompt. |
| Denegar | deny | Calls addToolApprovalResponse({ id, approved: false }). The model receives execution-denied. |
| Decir algo distinto | deny with reason | Reveals 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 partsQuery Keys
| Key | Used by |
|---|---|
['agent-internal-tools', agentId] | useAgentInternalToolsCatalog, useUpsertAgentInternalTools |
Shared Zod Schemas
The service layer validates API responses against schemas from @repo/schemas:
| Schema | Purpose |
|---|---|
agentInternalToolCatalogResponseSchema | Validates GET catalog response shape |
agentToolUpsertResponseSchema | Validates PUT tools response shape |
agentToolOverrideResponseSchema | Validates POST/GET override response shape |
agentToolOverrideListResponseSchema | Validates GET overrides list response shape |
V1 Constraints
- Only the
operationsprovider 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-uiTabs) replaced the placeholder that previously existed in step 7.