Summary
Enable users to mount custom JavaScript tools into the frontend for both production (Helm-deployed Docker image) and local development (npm run dev). The frontend dynamically loads these tools, presents a multi-select toggle near the chat prompt for enabling/disabling them per-thread, and executes tool calls client-side when the backend/LLM requests them. Tools are discovered via a tools.json configuration file and individual JS modules served as static assets.
Motivation
The chat pack is frequently used in live demos and workshops with audiences that include non-experts. Explaining concepts like agents, models, and tools in the abstract is difficult — toggling individual tools on and off during a live demo makes these concepts tangible and natural. An audience can see what happens when a tool is available vs. disabled, observe the tool call in action, and understand the difference between the model's reasoning and its tool usage. The per-thread toggle UI is designed specifically to support this interactive, step-by-step demonstration format.
Goals
- Allow users to add custom JS/TS tools to the frontend container without rebuilding the image.
- Support the same tool workflow during local development (
npm run dev) without Docker or Helm.
- Provide a UI for selecting which tools are active, persisted per-thread in
localStorage.
- Execute frontend tool calls client-side: intercept tool call events from the backend, run the corresponding JS function, and restart the run with the result.
- Ship tools via Helm chart values, supporting both inline definitions and external ConfigMap references.
Non-Goals
- Building or bundling tool code at Helm install time. Tool JS files must be served as-is by nginx.
- Tool execution on the backend. Frontend tools are purely client-side.
- Cross-device persistence of tool state. State is per-browser via
localStorage.
- Editing or creating tools through the UI. Tools are defined declaratively via Helm or, in development, via a local
tools/ directory.
Background / Motivation
AG-UI supports frontend tools by accepting a tools array (JSON schemas) in the run input sent to /api/threads/{id}/run. When the LLM decides to call a tool, it emits ToolCallStart, ToolCallArgs, and ToolCallEnd SSE events, then ends the run. The frontend must then execute the tool and restart the run with the tool result.
Currently, the frontend passes tools: [] in every run (see hooks.ts TODO comment). Tool calls are purely backend-executed and rendered for display. This design adds full client-side tool support.
Design
1. Tool Configuration File (tools.json)
A JSON file served at /tools/tools.json. In production, this is a static asset mounted into the nginx container via Helm. In development, it is served by the Vite dev server from frontend/public/tools/tools.json.
[
{
"tool": {
"name": "get_weather",
"description": "Get current weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": { "type": "string", "description": "City name" }
},
"required": ["location"]
}
},
"importPath": "/tools/weather.js",
"entrypoint": "fetchWeather"
}
]
tool: The full JSON schema object for the tool, as expected by AG-UI (name, description, parameters). The name field also serves as the unique identifier for the tool throughout the system (used as the key in localStorage, the lookup key when matching ToolCallStart events, and the display label). No separate id field is needed.
importPath: The URL path to load the JS module via dynamic import(). In production, served by nginx. In development, served by the Vite dev server.
entrypoint: The name of the named export to call in the loaded module.
Authoring language: Tools should be written in TypeScript for type safety and developer experience. However, the browser cannot load .ts files directly — tool modules must be transpiled to JavaScript (ES modules) before being placed in the tools/ directory. The importPath must reference the transpiled .js file.
Each JS tool module exports one or more named async functions:
// /tools/weather.js
export async function fetchWeather(args) {
const { location } = args;
// ... execute logic ...
return { temperature: 72, conditions: "sunny" };
}
The function receives parsed JSON arguments and may return any JSON-serializable value. The frontend handles JSON.stringify() on the result before embedding it in the content field of the tool role message.
2. Frontend Tool Discovery
Add a new API utility module frontend/src/api/tools.ts:
export interface ToolConfig {
tool: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
importPath: string;
entrypoint: string;
}
export async function getToolConfig(): Promise<ToolConfig[]> {
const resp = await fetch('/tools/tools.json');
if (!resp.ok) return [];
return resp.json();
}
The app config is extended to include the loaded tools. A new context or integration with the existing AppConfig makes tools available throughout the app.
3. Tool Execution Flow
The tool execution loop in useOnSubmit / the run handler:
- Send run: Include all active frontend tool schemas in the
tools field of createRun.Options for every run, regardless of whether it's a user prompt or a tool result continuation.
- Stream events: Process events normally.
ToolCallStart/ToolCallArgs/ToolCallEnd events are accumulated for tool calls whose name matches a loaded frontend tool.
- Detect pending tool calls: After
RUN_FINISHED, check for any frontend tool calls that were started but not resolved (no corresponding ToolCallResult event came from the backend — because backend tools produce results, frontend tools don't).
- Execute tools: For each pending frontend tool call:
- Dynamically
import() the tool module from importPath.
- Call the named
entrypoint function with the parsed arguments.
- Collect the result string.
- Restart run: Start a new run with a single
tool role message (or multiple if there were multiple pending tool calls):
{
"messages": [
{
"role": "tool",
"id": "<uuid>",
"toolCallId": "<tool_call_id>",
"content": "<JSON string result>"
}
],
"tools": [ ...frontend tool schemas... ]
}
- Repeat: Continue until the run finishes with no pending frontend tool calls.
4. Per-Thread Tool State
Tool enabled/disabled state is stored in localStorage with key format chat:tools:{threadId}:
{ "get_weather": true, "search_docs": false }
- When no thread exists (new chat), use a default key
chat:tools:default.
- On thread switch, load the state from the corresponding key.
- Default: all tools are disabled.
- Provide utility functions in a new
frontend/src/lib/tools.ts module:
function loadToolState(threadId: string | null): Record<string, boolean>;
function saveToolState(threadId: string | null, state: Record<string, boolean>): void;
function getActiveToolNames(threadId: string | null, allTools: ToolConfig[]): ToolConfig[];
5. Tool Selector UI
A button placed inside ChatInput next to the file upload (paperclip) button. When clicked, it opens an overlayed panel docked above the button.
Closed state
A button containing:
- A sliders-horizontal icon (Lucide
sliders-horizontal).
- The text "Tools".
- A badge showing the number of currently active frontend tools (hidden when count is 0).
- A down-pointing caret indicating the button opens an overlayed panel.
Opened panel
The panel is docked directly above the button (toward the top of the viewport). It lists all available tools in two sections: Frontend Tools and Backend Tools.
Each tool entry has the same left-side layout:
- Tool name in bold.
- Tool description below the name in muted/smaller text.
Frontend tools (right side):
- A toggle switch to enable/disable the tool.
- Toggles default to off.
- State is persisted to
localStorage per-thread.
Backend tools (right side):
- A lock icon (Lucide
lock-keyhole) instead of a toggle.
- Backend tools are always active — they are baked into the agent configuration and cannot be changed from the frontend.
Backend tool data source
Backend tool names and descriptions are parsed from the agent's capabilities.tools field, which is part of the AG-UI AgentCapabilitiesSchema from @ag-ui/core. This data is returned by /api/config and is already loaded into useAppConfig().agents.
Implementation:
- New component:
frontend/src/chat/toolselector.tsx
- Uses
@/components/ui/tooltip (for the button), @/components/ui/badge (for the active count), and custom panel overlay (positioned relative to the button).
- Integrated into
ChatInput, positioned to the left of the file upload button.
- Backend tools sourced from
useAppConfig().agents → capabilities.tools.
- Frontend tools sourced from the tools context (loaded via
getToolConfig()).
- The active frontend tool list flows down to the
useOnSubmit hook, which includes them in every run.
6. Local Development Support
Tools must work during npm run dev without Docker or Helm.
Tool directory layout:
frontend/
public/
tools/
tools.json # Tool config (same format as production)
weather.js # Tool JS module
Files in frontend/public/tools/ are served as-is by the Vite dev server at /tools/, matching the production URL structure. This means the same tools.json and importPath values work in both environments.
Development workflow:
- Place
tools.json and tool JS files in frontend/public/tools/.
- Run
npm run dev — tools are automatically available.
- For TypeScript tool development, users should write
.ts files (e.g., in frontend/tools/) and transpile them to .js output in frontend/public/tools/. A simple tsc script or Vite config can handle this. The browser can only load the transpiled .js files.
7. Helm Chart Changes
values.yaml additions
frontend:
# ... existing values ...
tools:
# List of tool definitions. Can be inline or reference a ConfigMap.
inline: []
# - name: get_weather
# tool:
# name: get_weather
# description: Get current weather
# parameters: { ... }
# importPath: /tools/weather.js
# entrypoint: fetchWeather
# source: inline
# External ConfigMap references containing tool files.
configMaps: []
# - name: my-tools-config
# mountPath: /tools
# items:
# - key: weather.js
# path: weather.js
New template: templates/frontend-tools-configmap.yaml
Generates a ConfigMap containing:
tools.json — assembled from inline tool definitions.
- Any JS files from inline tool
source fields.
Modified template: templates/frontend-deployment.yaml
- Add a volume mount for the tools ConfigMap at
/usr/share/nginx/html/tools.
- If user-provided ConfigMaps are referenced (
frontend.tools.configMaps), mount those as additional volumes.
Modified: values.schema.json
Add schema definitions for frontend.tools.inline and frontend.tools.configMaps.
8. File Changes Summary
| File |
Change |
frontend/src/api/tools.ts |
New — ToolConfig type, getToolConfig() fetcher |
frontend/src/lib/tools.ts |
New — localStorage persistence utilities |
frontend/src/chat/toolselector.tsx |
New — tool selector button + overlayed panel with frontend and backend tool listings |
frontend/src/chat/chatinput.tsx |
Modified — integrate tool selector button next to the file upload button |
frontend/src/chat/hooks.ts |
Modified — pass active tool schemas to createRun, detect pending frontend tool calls, execute tools, restart run |
frontend/src/routes/_authenticated/chat.tsx |
Modified — preload tool config in route loader |
frontend/src/context/index.ts |
Modified — export new tool context |
frontend/src/context/tools.ts |
New — tool config context and hook |
helm/nebari-chat/values.yaml |
Modified — add frontend.tools section |
helm/nebari-chat/values.schema.json |
Modified — schema for tools |
helm/nebari-chat/templates/frontend-tools-configmap.yaml |
New — ConfigMap for inline tools |
helm/nebari-chat/templates/frontend-deployment.yaml |
Modified — mount tools volume |
Tradeoffs & Risks
-
Dynamic import() in production: Tool authoring should be done in TypeScript for type safety, but the browser cannot load .ts files. Tools must be transpiled to JavaScript ES modules before being placed in the tools/ directory. This is an inherent constraint of the browser runtime and should be clearly documented in the developer workflow.
-
Tool code security: User-provided JS runs in the browser with full access to the DOM and any credentials in scope. Tools can make network requests using the browser's cookies/tokens. This is an inherent risk of client-side tools. Mitigation: document the security implications and recommend tools be audited before deployment.
-
localStorage limits: localStorage has a ~5MB limit per origin, but we only store boolean flags keyed by thread ID, so this is negligible. However, localStorage is per-browser — switching devices loses tool preferences. Acceptable for the MVP.
-
Multiple tool calls per run: If the LLM calls multiple frontend tools in a single turn, the frontend executes all of them sequentially (or in parallel) before restarting the run. Parallel execution is faster but could cause ordering issues if tools have side effects. We execute them in parallel (Promise.all) for speed, accepting the risk.
-
No secrets in frontend tools: Frontend tools run in the browser, so there is no mechanism for securely storing service-level secrets (API keys, database credentials, internal tokens). Anything in the tool JS, config, or container is visible via DevTools. Frontend tools can only safely use the user's own auth token (already available in the browser) or credentials explicitly provided by the user through a separate UI. If a tool requires a service secret, it belongs on the backend. This limits the class of tools suitable for frontend execution to: calling public APIs, calling services where the user's token suffices, or calling user-provided keys.
-
Tools ConfigMap size limits: Kubernetes ConfigMaps are limited to 1MB. For large tool codebases, users should use external storage or bake tools into a custom image.
Open Questions
- Component visibility — With backend tools now displayed in the panel, should the tool selector button be hidden only when there are no frontend tools (as before), or should it also appear when backend tools exist so users can at least see what tools the agent has? Showing it for backend-only scenarios would give users visibility into agent capabilities but adds UI chrome that isn't actionable (no toggling possible). Options:
- Hide when no frontend tools exist (current spec) — clean UI, but hides agent tool info.
- Always show if any tools exist (frontend or backend) — full visibility, but shows a non-interactive button when only backend tools are present.
- Always show — consistent placement, badge/count reflects frontend tools, panel shows backend tools as reference.
Summary
Enable users to mount custom JavaScript tools into the frontend for both production (Helm-deployed Docker image) and local development (
npm run dev). The frontend dynamically loads these tools, presents a multi-select toggle near the chat prompt for enabling/disabling them per-thread, and executes tool calls client-side when the backend/LLM requests them. Tools are discovered via atools.jsonconfiguration file and individual JS modules served as static assets.Motivation
The chat pack is frequently used in live demos and workshops with audiences that include non-experts. Explaining concepts like agents, models, and tools in the abstract is difficult — toggling individual tools on and off during a live demo makes these concepts tangible and natural. An audience can see what happens when a tool is available vs. disabled, observe the tool call in action, and understand the difference between the model's reasoning and its tool usage. The per-thread toggle UI is designed specifically to support this interactive, step-by-step demonstration format.
Goals
npm run dev) without Docker or Helm.localStorage.Non-Goals
localStorage.tools/directory.Background / Motivation
AG-UI supports frontend tools by accepting a
toolsarray (JSON schemas) in the run input sent to/api/threads/{id}/run. When the LLM decides to call a tool, it emitsToolCallStart,ToolCallArgs, andToolCallEndSSE events, then ends the run. The frontend must then execute the tool and restart the run with the tool result.Currently, the frontend passes
tools: []in every run (seehooks.tsTODO comment). Tool calls are purely backend-executed and rendered for display. This design adds full client-side tool support.Design
1. Tool Configuration File (
tools.json)A JSON file served at
/tools/tools.json. In production, this is a static asset mounted into the nginx container via Helm. In development, it is served by the Vite dev server fromfrontend/public/tools/tools.json.[ { "tool": { "name": "get_weather", "description": "Get current weather for a location", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "City name" } }, "required": ["location"] } }, "importPath": "/tools/weather.js", "entrypoint": "fetchWeather" } ]tool: The full JSON schema object for the tool, as expected by AG-UI (name,description,parameters). Thenamefield also serves as the unique identifier for the tool throughout the system (used as the key inlocalStorage, the lookup key when matchingToolCallStartevents, and the display label). No separateidfield is needed.importPath: The URL path to load the JS module via dynamicimport(). In production, served by nginx. In development, served by the Vite dev server.entrypoint: The name of the named export to call in the loaded module.Authoring language: Tools should be written in TypeScript for type safety and developer experience. However, the browser cannot load
.tsfiles directly — tool modules must be transpiled to JavaScript (ES modules) before being placed in thetools/directory. TheimportPathmust reference the transpiled.jsfile.Each JS tool module exports one or more named async functions:
The function receives parsed JSON arguments and may return any JSON-serializable value. The frontend handles
JSON.stringify()on the result before embedding it in thecontentfield of thetoolrole message.2. Frontend Tool Discovery
Add a new API utility module
frontend/src/api/tools.ts:The app config is extended to include the loaded tools. A new context or integration with the existing
AppConfigmakes tools available throughout the app.3. Tool Execution Flow
The tool execution loop in
useOnSubmit/ the run handler:toolsfield ofcreateRun.Optionsfor every run, regardless of whether it's a user prompt or a tool result continuation.ToolCallStart/ToolCallArgs/ToolCallEndevents are accumulated for tool calls whose name matches a loaded frontend tool.RUN_FINISHED, check for any frontend tool calls that were started but not resolved (no correspondingToolCallResultevent came from the backend — because backend tools produce results, frontend tools don't).import()the tool module fromimportPath.entrypointfunction with the parsed arguments.toolrole message (or multiple if there were multiple pending tool calls):{ "messages": [ { "role": "tool", "id": "<uuid>", "toolCallId": "<tool_call_id>", "content": "<JSON string result>" } ], "tools": [ ...frontend tool schemas... ] }4. Per-Thread Tool State
Tool enabled/disabled state is stored in
localStoragewith key formatchat:tools:{threadId}:{ "get_weather": true, "search_docs": false }chat:tools:default.frontend/src/lib/tools.tsmodule:5. Tool Selector UI
A button placed inside
ChatInputnext to the file upload (paperclip) button. When clicked, it opens an overlayed panel docked above the button.Closed state
A button containing:
sliders-horizontal).Opened panel
The panel is docked directly above the button (toward the top of the viewport). It lists all available tools in two sections: Frontend Tools and Backend Tools.
Each tool entry has the same left-side layout:
Frontend tools (right side):
localStorageper-thread.Backend tools (right side):
lock-keyhole) instead of a toggle.Backend tool data source
Backend tool names and descriptions are parsed from the agent's
capabilities.toolsfield, which is part of the AG-UIAgentCapabilitiesSchemafrom@ag-ui/core. This data is returned by/api/configand is already loaded intouseAppConfig().agents.Implementation:
frontend/src/chat/toolselector.tsx@/components/ui/tooltip(for the button),@/components/ui/badge(for the active count), and custom panel overlay (positioned relative to the button).ChatInput, positioned to the left of the file upload button.useAppConfig().agents→capabilities.tools.getToolConfig()).useOnSubmithook, which includes them in every run.6. Local Development Support
Tools must work during
npm run devwithout Docker or Helm.Tool directory layout:
Files in
frontend/public/tools/are served as-is by the Vite dev server at/tools/, matching the production URL structure. This means the sametools.jsonandimportPathvalues work in both environments.Development workflow:
tools.jsonand tool JS files infrontend/public/tools/.npm run dev— tools are automatically available..tsfiles (e.g., infrontend/tools/) and transpile them to.jsoutput infrontend/public/tools/. A simpletscscript or Vite config can handle this. The browser can only load the transpiled.jsfiles.7. Helm Chart Changes
values.yamladditionsNew template:
templates/frontend-tools-configmap.yamlGenerates a ConfigMap containing:
tools.json— assembled from inline tool definitions.sourcefields.Modified template:
templates/frontend-deployment.yaml/usr/share/nginx/html/tools.frontend.tools.configMaps), mount those as additional volumes.Modified:
values.schema.jsonAdd schema definitions for
frontend.tools.inlineandfrontend.tools.configMaps.8. File Changes Summary
frontend/src/api/tools.tsToolConfigtype,getToolConfig()fetcherfrontend/src/lib/tools.tsfrontend/src/chat/toolselector.tsxfrontend/src/chat/chatinput.tsxfrontend/src/chat/hooks.tscreateRun, detect pending frontend tool calls, execute tools, restart runfrontend/src/routes/_authenticated/chat.tsxfrontend/src/context/index.tsfrontend/src/context/tools.tshelm/nebari-chat/values.yamlfrontend.toolssectionhelm/nebari-chat/values.schema.jsonhelm/nebari-chat/templates/frontend-tools-configmap.yamlhelm/nebari-chat/templates/frontend-deployment.yamlTradeoffs & Risks
Dynamic
import()in production: Tool authoring should be done in TypeScript for type safety, but the browser cannot load.tsfiles. Tools must be transpiled to JavaScript ES modules before being placed in thetools/directory. This is an inherent constraint of the browser runtime and should be clearly documented in the developer workflow.Tool code security: User-provided JS runs in the browser with full access to the DOM and any credentials in scope. Tools can make network requests using the browser's cookies/tokens. This is an inherent risk of client-side tools. Mitigation: document the security implications and recommend tools be audited before deployment.
localStorage limits:
localStoragehas a ~5MB limit per origin, but we only store boolean flags keyed by thread ID, so this is negligible. However, localStorage is per-browser — switching devices loses tool preferences. Acceptable for the MVP.Multiple tool calls per run: If the LLM calls multiple frontend tools in a single turn, the frontend executes all of them sequentially (or in parallel) before restarting the run. Parallel execution is faster but could cause ordering issues if tools have side effects. We execute them in parallel (
Promise.all) for speed, accepting the risk.No secrets in frontend tools: Frontend tools run in the browser, so there is no mechanism for securely storing service-level secrets (API keys, database credentials, internal tokens). Anything in the tool JS, config, or container is visible via DevTools. Frontend tools can only safely use the user's own auth token (already available in the browser) or credentials explicitly provided by the user through a separate UI. If a tool requires a service secret, it belongs on the backend. This limits the class of tools suitable for frontend execution to: calling public APIs, calling services where the user's token suffices, or calling user-provided keys.
Tools ConfigMap size limits: Kubernetes ConfigMaps are limited to 1MB. For large tool codebases, users should use external storage or bake tools into a custom image.
Open Questions