Skip to Content

Custom Tool UI Architecture

This document describes how dynamically-loaded tool UI components work in AgentPress, enabling organizations to create custom React components that render tool outputs.

Overview

AgentPress supports two types of tool UIs:

TypeLocationUse Case
Staticpackages/custom/src/ui/Built-in tools, bundled at compile time
DynamicS3 via Tool BuilderUser-created tools, loaded at runtime

Priority: Dynamic Tool UIs take priority over Static Tool UIs. This allows user-created UIs to override built-in ones.

Dynamic tool UIs are TSX components created in the Tool Builder that get compiled to JavaScript bundles and served from S3.

Architecture Diagram

Server Side: Publish Flow

Client Side: Load Flow

Component Registration

Key Components

Server Side

Bundle Compiler

File: packages/api/src/services/bundleCompiler.ts

Compiles TSX components into a single self-registering JavaScript bundle.

interface ComponentSource { name: string; // Component name (e.g., "WeatherCard") source: string; // TSX source code toolsMapped: string[]; // Tool names this component handles (e.g., ["getWeather"]) } async function compileAllUIComponents( components: ComponentSource[] ): Promise<CompileResult>

Compilation Process:

  1. Strip imports - All imports are removed since dependencies come from globals
  2. Transform exports - export const X becomes const X, then extracted
  3. Wrap in IIFE - Each component wrapped to prevent variable collisions
  4. Add registration - Calls window.__AgentPressToolUI.register(name, component)
  5. Compile with esbuild - TSX → minified JavaScript

Example Output:

(()=>{ const React=window.__AgentPressDeps.React; const {useState,useEffect}=React; // WeatherCard component (mapped to "getWeather" tool) (function(){ const WeatherCard=({toolPart})=>{...}; // Register for each mapped tool name ["getWeather"].forEach(toolName => { window.__AgentPressToolUI?.register(toolName, WeatherCard); }); // Also register by component name as fallback window.__AgentPressToolUI?.register("WeatherCard", WeatherCard); })(); // StockChart component (mapped to "getStockPrice" tool) (function(){ const StockChart=({toolPart})=>{...}; ["getStockPrice"].forEach(toolName => { window.__AgentPressToolUI?.register(toolName, StockChart); }); window.__AgentPressToolUI?.register("StockChart", StockChart); })(); })();

Tool Storage Service

File: packages/api/src/services/toolStorageService.ts

Manages storage and retrieval of tool files in S3.

// Save compiled bundle async saveCombinedUIBundle(orgId: string, bundle: string): Promise<void> // Retrieve compiled bundle async getCombinedUIBundle(orgId: string): Promise<string | null>

S3 Structure:

orgs/{orgId}/ ├── draft/ │ └── ui/{componentName}/ │ ├── component.tsx # Source TSX │ └── samples.json # Preview data └── live/ ├── ui/{componentName}/ │ ├── component.tsx # Source (reference) │ └── samples.json └── ui-bundle.js # Compiled bundle

Client Side

Custom Tool Runtime

File: packages/ui/src/lib/customToolRuntime.ts

Sets up global registry for dynamic components.

// Initialize once at app startup function initCustomToolRuntime(React: typeof import("react")): void // Look up a registered component function getCustomToolUI(toolName: string): TToolUI | undefined

Global Objects:

ObjectPurpose
window.__AgentPressDepsShared dependencies (React, motion)
window.__AgentPressToolUIComponent registry with register() and get()

Custom Tool Loader

File: packages/ui/src/lib/customToolLoader.ts

Handles runtime initialization and dynamic script loading.

// React hook that initializes runtime and loads bundles after auth function useCustomToolUIs(): { isLoading: boolean; isLoaded: boolean; error: string | null; } // Load the combined bundle for an organization (lower-level) async function loadOrgToolUIs( orgSlug: string, options?: { apiHost?: string } ): Promise<{ success: boolean; error?: string }> // Check if a specific tool UI is loaded function isToolUILoaded(toolName: string): boolean // Check if the bundle has been loaded function isBundleLoaded(): boolean

Integration Points

Using in Your App

File: apps/console/src/routes.tsx or apps/portal/src/routes.tsx

The useCustomToolUIs hook handles everything - runtime initialization and bundle loading:

import { useCustomToolUIs } from "@agentpress/ui/lib"; // Create a loader component that uses the hook function CustomToolUILoader() { const { error } = useCustomToolUIs(); if (error) { console.warn("Failed to load custom tool UIs:", error); } return null; } // Add it inside your provider const rootRoute = createRootRoute({ component: () => ( <AgentPressProvider> <CustomToolUILoader /> <Outlet /> </AgentPressProvider> ), });

The hook automatically:

  1. Initializes the runtime (React globals for dynamic bundles)
  2. Waits for user authentication and org context
  3. Loads the dynamic bundle from the API

Tool Component

File: packages/ui/src/core/Tool.tsx

import { getAgentPressConfig } from "@agentpress/lib/config"; import { getCustomToolUI } from "@agentpress/ui/lib"; function Tool({ toolPart }) { const toolName = toolPart.type.substring(5); // 'tool-getWeather' -> 'getWeather' const toolsUI = getAgentPressConfig().toolsUI; // Check for custom tool UI from both dynamic registry and static config // Dynamic Tool UI (from Tool Builder) takes priority over Static Tool UI (built-in) const dynamicToolUI = getCustomToolUI(toolName); const staticToolUI = toolName in toolsUI ? toolsUI[toolName] : null; const ToolComponent = dynamicToolUI || staticToolUI || null; if (ToolComponent) { return <ToolComponent toolPart={toolPart} />; } // Fallback to default JSON view return <DefaultToolResult toolPart={toolPart} />; }

Component Contract

Dynamic UI components must follow this interface:

type TToolUI = React.FC<{ toolPart?: { toolName: string; toolCallId: string; args: Record<string, unknown>; output?: unknown; state: "pending" | "result" | "error"; }; }>;

Example Component:

export const ToolResultRenderer = ({ toolPart }) => { const result = toolPart?.output as { temperature: number; condition: string; location: string; }; if (!result) { return <div>Loading...</div>; } return ( <div className="p-4 rounded-lg bg-card"> <h3>{result.location}</h3> <p>{result.temperature}°F - {result.condition}</p> </div> ); };

Available Dependencies

Components have access to these globals:

DependencyAccessPurpose
Reactwindow.__AgentPressDeps.ReactReact library
motionwindow.__AgentPressDeps.motionFramer Motion animations
useStateDestructured from ReactState hook
useEffectDestructured from ReactEffect hook
useCallbackDestructured from ReactCallback memoization
useMemoDestructured from ReactValue memoization
useRefDestructured from ReactRef hook
useContextDestructured from ReactContext hook
useReducerDestructured from ReactReducer hook
useLayoutEffectDestructured from ReactLayout effect hook

Security Model

Trust Level: Internal team/admin only

ProtectionDescription
Org IsolationBundles stored per-org in S3 (orgs/{orgId}/live/)
Admin-Only PublishPublish endpoint requires admin authentication
Per-Org LoadingloadOrgToolUIs(orgSlug) only loads that org’s bundle

No additional sandboxing is implemented since only trusted users create components.

API Endpoints

Get Combined Bundle

GET /orgs/:orgSlug/tool-storage/ui/bundle

Response: JavaScript bundle (Content-Type: application/javascript)

Headers:

  • Cache-Control: public, max-age=3600 (1 hour)

Troubleshooting

Component Not Rendering

  1. Check bundle loaded: Open DevTools Network tab, verify /orgs/:orgSlug/tool-storage/ui/bundle request succeeded (404 is OK if no bundle published yet)
  2. Check registration: In console, run window.__AgentPressToolUI.getAll() to see registered components
  3. Check tool name match: Components are registered by tool name (from toolsMapped), not component name. The tool’s toolName must match a registered key.

Compilation Errors

  1. Syntax errors: Use validateFile tool in Tool Builder before publishing
  2. Missing exports: Component must export something (e.g., export const ToolResultRenderer)
  3. Import issues: Don’t import external packages; use provided globals only

React Errors

  1. Multiple React instances: Ensure component uses window.__AgentPressDeps.React, not a bundled React
  2. Hook errors: Use hooks from destructured React, not imported ones

Animation Issues

Dynamic Tool UIs handle their own animations. The Tool.tsx component:

  1. No motion wrapper: Dynamic Tool UIs are not wrapped in motion.div (Static Tool UIs are)
  2. Shallow comparison: Uses shallow value comparison for output object to prevent re-renders during text streaming
  3. Stable selectors: Uses Zustand selectors to prevent re-renders from unrelated store updates

If animations are glitching, check that your component’s key props are stable and use memoization where needed.

File Reference

FilePurpose
packages/api/src/services/bundleCompiler.tsTSX → JS compilation
packages/api/src/services/toolStorageService.tsS3 storage management
packages/api/src/routes/toolStorage.tsAPI endpoints
packages/ui/src/lib/customToolRuntime.tsGlobal registry setup
packages/ui/src/lib/customToolLoader.tsDynamic script loader
packages/ui/src/core/Tool.tsxComponent lookup & render
Last updated on