.agents/skills/builtin-tool/references/ui.md
A builtin tool can ship up to six client-side surfaces, each with a different role in the chat UI. Only Inspector is required; the other five are added on demand and registered in their own central files.
| Surface | Required? | When the chat shows it | Registered in |
|---|---|---|---|
| Inspector | ✅ Always | Header strip of every tool call (one-line chip) | packages/builtin-tools/src/inspectors.ts |
| Render | Optional | Rich result card below the header, after the call returns | packages/builtin-tools/src/renders.ts |
| Placeholder | Optional | Skeleton between "args streaming complete" and "result arrives" | packages/builtin-tools/src/placeholders.ts |
| Streaming | Optional | Live output during execution (e.g. command stdout) | packages/builtin-tools/src/streamings.ts |
| Intervention | Optional | Approval / edit-before-run dialog (when humanIntervention triggers) | packages/builtin-tools/src/interventions.ts |
| Portal | Optional | Full-screen detail view (right-side or modal) | packages/builtin-tools/src/portals.ts |
The two reference tools to read end-to-end:
builtin-tool-web-browsing/src/client/ — Inspector + Render + Placeholder + Portal (no Intervention/Streaming).builtin-tool-local-system/src/client/ — all six surfaces, including components/ for shared building blocks.These apply across every surface.
'use client' at the top of every component fileTool surfaces are leaves in the chat tree and must not block server rendering.
createStaticStyles + cssVar.*Zero-runtime CSS-in-JS — the styles compile once and read CSS variables at runtime.
import { createStaticStyles, cssVar } from 'antd-style';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
padding-block: 2px;
padding-inline: 8px;
border-radius: 999px;
color: ${cssVar.colorText};
background: ${cssVar.colorFillTertiary};
`,
}));
Fall back to createStyles + token only when you need runtime token computation (rare). Inline style={{ color: cssVar.colorTextSecondary }} is fine for one-off dynamic values.
@lobehub/ui, not raw antdBlock, Text, Flexbox, Highlighter, Alert, Tooltip, Skeleton all come from @lobehub/ui. Modals come from @lobehub/ui/base-ui (createModal, useModalContext, confirmModal) — see the modal skill.
Memory note: @lobehub/ui's <Text type='secondary'> is a lighter shade than colorTextSecondary. If you need that exact token color, write <Text style={{ color: cssVar.colorTextSecondary }}>.
memo and set displayNameexport const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args /* … */ }) => {
/* … */
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
BuiltinXProps<Args, State> genericsDon't widen to any. The Args generic is the JSON Schema params, the State generic is the executor's state field. The two should match <Name>Params and <Name>State from types.ts.
t('plugin')const { t } = useTranslation('plugin');
t('builtins.<identifier>.apiName.<api>');
Every Inspector should default to t('builtins.<identifier>.apiName.<api>') so it shows something while args stream in.
@/store/chat, not propsTool surfaces sometimes need cross-cutting state (loading, streaming buffer). Read it inside the component via Zustand selectors, not from props — props only carry args/state/messageId.
Lifecycle: Inspector renders for every phase of a tool call: while args are streaming in, while the executor is running, and after results come back. It's the only surface that's always visible.
Goal: keep it to a single line. Show what's happening with as much context as is currently available.
BuiltinInspectorProps<Args, State>)interface BuiltinInspectorProps<Arguments = any, State = any> {
apiName: string;
args: Arguments; // final args (only after the assistant stops streaming)
identifier: string;
isArgumentsStreaming?: boolean; // args still arriving
isLoading?: boolean; // args complete, executor running
partialArgs?: Arguments; // partial JSON during streaming
pluginState?: State; // executor's `state` after success
result?: { content: string | null; error?: any };
}
| Phase | What's available | What to show |
|---|---|---|
| Args streaming, no useful field yet | isArgumentsStreaming === true, partialArgs.X undefined | Just the API title with shinyTextStyles.shinyText |
| Args streaming, key field arrived | partialArgs.X populated | Title + key field chip, still pulse-animated |
| Args complete, executor running | args populated, isLoading === true | Same as above, still pulse-animated |
| Result arrived | pluginState populated, isLoading === false | Title + chips + result summary (count, identifier, status) |
packages/builtin-tool-web-browsing/src/client/Inspector/Search/index.tsx:
'use client';
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { Text } from '@lobehub/ui';
import { cssVar, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const query = args?.query || partialArgs?.query || '';
const resultCount = pluginState?.results?.length ?? 0;
const hasResults = resultCount > 0;
if (isArgumentsStreaming && !query) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-web-browsing.apiName.search')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-web-browsing.apiName.search')}: </span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
!isArgumentsStreaming &&
pluginState?.results &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
) : (
<Text as="span" color={cssVar.colorTextDescription} fontSize={12}>
({t('builtins.lobe-web-browsing.inspector.noResults')})
</Text>
))}
</div>
);
},
);
SearchInspector.displayName = 'SearchInspector';
export default SearchInspector;
inspectorTextStyles.root (provides correct flex / line-height baseline).shinyTextStyles.shinyText whenever isArgumentsStreaming || isLoading.args?.X and partialArgs?.X together — args is final, partialArgs is in-stream.text-overflow: ellipsis and have a max-width so long values don't blow out the chat bubble.pluginState-derived suffixes only after loading finishes — count or "(no results)" should not appear while still searching.client/Inspector/index.tsimport type { BuiltinInspector } from '@lobechat/types';
import { TaskApiName } from '../../types';
import { CreateTaskInspector } from './CreateTask';
import { ListTasksInspector } from './ListTasks';
/* … */
export const TaskInspectors: Record<string, BuiltinInspector> = {
[TaskApiName.createTask]: CreateTaskInspector as BuiltinInspector,
[TaskApiName.listTasks]: ListTasksInspector as BuiltinInspector,
/* one entry per ApiName */
};
export { CreateTaskInspector } from './CreateTask';
export { ListTasksInspector } from './ListTasks';
/* re-export each */
Lifecycle: rendered once the result arrives (after Placeholder/Streaming hand off). Sits below the Inspector header.
Skip if the API is read-only or the result is just text — the framework already shows the executor's content string. Add a Render only when there's a structured artifact worth seeing: a card, a chart, a diff, a list of files.
BuiltinRenderProps<Args, State, Content>)interface BuiltinRenderProps<Arguments = any, State = any, Content = any> {
apiName?: string;
args: Arguments; // final params from the LLM
content: Content; // executor's content string (or parsed)
identifier?: string;
messageId: string; // for store lookups
pluginError?: any; // from BuiltinToolResult.error
pluginState?: State; // executor's state
toolCallId?: string;
}
Pattern A — Single-file Render (web-browsing CrawlSinglePage):
// client/Render/CrawlSinglePage.tsx
import type { BuiltinRenderProps, CrawlPluginState, CrawlSinglePageQuery } from '@lobechat/types';
import { memo } from 'react';
import PageContent from './PageContent';
const CrawlSinglePage = memo<BuiltinRenderProps<CrawlSinglePageQuery, CrawlPluginState>>(
({ messageId, pluginState, args }) => (
<PageContent messageId={messageId} results={pluginState?.results} urls={[args?.url]} />
),
);
export default CrawlSinglePage;
Pattern B — Folder with subcomponents (web-browsing Search):
client/Render/Search/
├── index.tsx # composes the subcomponents, handles error states
├── ConfigForm.tsx # appears when pluginError.type === 'PluginSettingsInvalid'
├── SearchQuery.tsx # editable query header
└── SearchResult.tsx # result list
Use Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
Renders are the canonical place to surface pluginError because the chat doesn't auto-render typed errors:
if (pluginError) {
if (pluginError?.type === 'PluginSettingsInvalid') {
return <ConfigForm id={messageId} provider={pluginError.body?.provider} />;
}
return (
<Alert
title={pluginError?.message}
type="error"
extra={<Highlighter language="json">{JSON.stringify(pluginError.body, null, 2)}</Highlighter>}
/>
);
}
null if there's nothing useful to draw yet (avoids empty cards during stream).pluginState for server-truth (ids, counts, server-assigned status) and args for what the LLM asked. Combine — neither alone is enough.@lobehub/ui/base-ui (createModal, useModalContext, confirmModal) — see the modal skill.client/Render/index.tsimport type { BuiltinRender } from '@lobechat/types';
import { TaskApiName } from '../../types';
import CreateTaskRender from './CreateTask';
import RunTasksRender from './RunTasks';
export const TaskRenders: Record<string, BuiltinRender> = {
[TaskApiName.createTask]: CreateTaskRender as BuiltinRender,
[TaskApiName.runTasks]: RunTasksRender as BuiltinRender,
/* only the APIs with rich result UI — others fall back to text content */
};
export { default as CreateTaskRender } from './CreateTask';
export { default as RunTasksRender } from './RunTasks';
If the Render should hide for certain results (e.g. ClaudeCode's TodoWrite hides when the agent is mid-stream), add a RenderDisplayControl to packages/builtin-tools/src/displayControls.ts. See ClaudeCodeRenderDisplayControls for the pattern.
Lifecycle: rendered when the args have finished streaming but the executor hasn't returned yet. Disappears when pluginState arrives. Bridges the moment of perceived lag.
Add for APIs with noticeable execution time: web search, network crawl, file list, large grep. Skip for instant ops (status flips, calculator).
BuiltinPlaceholderProps<Args>)interface BuiltinPlaceholderProps<T extends Record<string, any> = any> {
apiName: string;
args?: T;
identifier: string;
}
No pluginState — Placeholder lives entirely in the "executing" gap.
packages/builtin-tool-web-browsing/src/client/Placeholder/Search.tsx:
import type { BuiltinPlaceholderProps, SearchQuery } from '@lobechat/types';
import { Flexbox, Icon, Skeleton } from '@lobehub/ui';
import { createStaticStyles, cx } from 'antd-style';
import { SearchIcon } from 'lucide-react';
import { memo } from 'react';
import { useIsMobile } from '@/hooks/useIsMobile';
import { shinyTextStyles } from '@/styles';
const styles = createStaticStyles(({ css, cssVar }) => ({
query: cx(
css`
padding: 4px 8px;
border-radius: 8px;
font-size: 12px;
color: ${cssVar.colorTextSecondary};
&:hover {
background: ${cssVar.colorFillTertiary};
}
`,
shinyTextStyles.shinyText,
),
}));
export const Search = memo<BuiltinPlaceholderProps<SearchQuery>>(({ args }) => {
const { query } = args || {};
const isMobile = useIsMobile();
return (
<Flexbox gap={8}>
<Flexbox horizontal={!isMobile} gap={isMobile ? 8 : 40}>
<Flexbox horizontal align="center" className={styles.query} gap={8}>
<Icon icon={SearchIcon} />
{query ? query : <Skeleton.Block active style={{ height: 20, width: 40 }} />}
</Flexbox>
<Skeleton.Block active style={{ height: 20, width: 40 }} />
</Flexbox>
<Flexbox horizontal gap={12}>
{[1, 2, 3, 4, 5].map((id) => (
<Skeleton.Button active key={id} style={{ borderRadius: 8, height: 80, width: 160 }} />
))}
</Flexbox>
</Flexbox>
);
});
Skeleton.Block / Skeleton.Button from @lobehub/ui for placeholder shapes.shinyTextStyles.shinyText if the Placeholder includes literal text.client/Placeholder/index.tsimport { WebBrowsingApiName } from '../../types';
import CrawlMultiPages from './CrawlMultiPages';
import CrawlSinglePage from './CrawlSinglePage';
import { Search } from './Search';
export const WebBrowsingPlaceholders = {
[WebBrowsingApiName.crawlMultiPages]: CrawlMultiPages,
[WebBrowsingApiName.crawlSinglePage]: CrawlSinglePage,
[WebBrowsingApiName.search]: Search,
};
export { CrawlMultiPages, CrawlSinglePage, Search };
Lifecycle: rendered while the executor is still running for APIs that emit incremental output. The component is responsible for fetching the in-flight stream from the chat store and rendering it.
Add for long-running ops with continuous output: shell command execution (stdout/stderr), file write progress, code interpreter cells.
BuiltinStreamingProps<Args>)interface BuiltinStreamingProps<Arguments = any> {
apiName: string;
args: Arguments;
identifier: string;
messageId: string; // use to fetch the streaming buffer from store
toolCallId: string;
}
Note there's no state or result prop — the Streaming component is for the in-flight phase. It pulls the live buffer from the store itself (typically via chatToolSelectors.streamingContent(messageId) or similar).
packages/builtin-tool-local-system/src/client/Streaming/RunCommand/index.tsx:
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
interface RunCommandParams {
command?: string;
description?: string;
timeout?: number;
}
export const RunCommandStreaming = memo<BuiltinStreamingProps<RunCommandParams>>(({ args }) => {
const { command } = args || {};
if (!command) return null;
return (
<Highlighter
animated
wrap
language="sh"
showLanguage={false}
style={{ padding: '4px 8px' }}
variant="outlined"
>
{command}
</Highlighter>
);
});
RunCommandStreaming.displayName = 'RunCommandStreaming';
For real-time output beyond just the command (stderr/stdout streaming), pull from the chat store:
const buffer = useChatStore((state) =>
chatToolSelectors.streamingBuffer(messageId, toolCallId)(state),
);
null until you have something to display (avoids flash).Highlighter with animated to show typing-like effect.client/Streaming/index.tsimport { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};
Lifecycle: rendered before the executor runs for APIs whose manifest sets humanIntervention. The user sees a preview of the args, can edit them, then approves or skips/cancels.
Add for destructive or sensitive ops: shell commands, file writes, file moves, payments, message broadcasts.
BuiltinInterventionProps<Args>)interface BuiltinInterventionProps<Arguments = any> {
apiName?: string;
args: Arguments;
identifier?: string;
interactionMode?: 'approval' | 'custom';
messageId: string;
/** Called when the user edits the args; the approve action awaits this. */
onArgsChange?: (args: Arguments) => void | Promise<void>;
/** Called on approve / skip / cancel. */
onInteractionAction?: (
action:
| { type: 'submit'; payload: Record<string, unknown> }
| { type: 'skip'; payload?: Record<string, unknown>; reason?: string }
| { type: 'cancel'; payload?: Record<string, unknown> },
) => Promise<void>;
/** Register a callback to flush pending saves before approval. Returns cleanup. */
registerBeforeApprove?: (id: string, callback: () => void | Promise<void>) => () => void;
}
packages/builtin-tool-local-system/src/client/Intervention/RunCommand/index.tsx:
import type { RunCommandParams } from '@lobechat/electron-client-ipc';
import type { BuiltinInterventionProps } from '@lobechat/types';
import { Flexbox, Highlighter, Text } from '@lobehub/ui';
import { memo } from 'react';
const RunCommand = memo<BuiltinInterventionProps<RunCommandParams>>(({ args }) => {
const { description, command, timeout } = args;
return (
<Flexbox gap={8}>
<Flexbox horizontal justify="space-between">
{description && <Text>{description}</Text>}
{timeout && (
<Text style={{ fontSize: 12 }} type="secondary">
timeout: {formatTimeout(timeout)}
</Text>
)}
</Flexbox>
{command && (
<Highlighter wrap language="sh" showLanguage={false} variant="outlined">
{command}
</Highlighter>
)}
</Flexbox>
);
});
export default RunCommand;
onArgsChange and is usually inline (click to edit a code block, etc.).registerBeforeApprove(id, flushFn) so the approve action waits for the debounce to flush. Always return the cleanup function.onInteractionAction({ type: 'submit', payload }) when the user approves; 'skip' if they skip with a reason; 'cancel' if they cancel the whole turn.interventionAudit.ts in the package root if the tool needs scope/path validation before approval (see local-system/src/interventionAudit.ts).client/Intervention/index.tsimport { LocalSystemApiName } from '../..';
import EditLocalFile from './EditLocalFile';
import RunCommand from './RunCommand';
import WriteFile from './WriteFile';
/* … */
export const LocalSystemInterventions = {
[LocalSystemApiName.editLocalFile]: EditLocalFile,
[LocalSystemApiName.runCommand]: RunCommand,
[LocalSystemApiName.writeLocalFile]: WriteFile,
/* one entry per API that needs approval */
};
Lifecycle: rendered when the user opens the tool message in a side panel or full-screen modal. One Portal per tool, not per API — the Portal switches on apiName internally.
Add for tools whose results deserve a deep-dive view: search results with editable filters, page content with reader mode, code interpreter sessions.
BuiltinPortalProps<Args, State>)interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
apiName?: string;
arguments: Arguments;
identifier: string;
messageId: string;
state: State;
}
packages/builtin-tool-web-browsing/src/client/Portal/index.tsx:
import type { BuiltinPortalProps, CrawlPluginState, SearchQuery } from '@lobechat/types';
import { memo } from 'react';
import { WebBrowsingApiName } from '../../types';
import PageContent from './PageContent';
import PageContents from './PageContents';
import Search from './Search';
const Portal = memo<BuiltinPortalProps>(({ arguments: args, messageId, state, apiName }) => {
switch (apiName) {
case WebBrowsingApiName.search:
return <Search messageId={messageId} query={args as SearchQuery} response={state} />;
case WebBrowsingApiName.crawlSinglePage: {
const result = (state as CrawlPluginState).results.find((r) => r.originalUrl === args.url);
return <PageContent messageId={messageId} result={result} />;
}
case WebBrowsingApiName.crawlMultiPages:
return (
<PageContents
messageId={messageId}
results={(state as CrawlPluginState).results}
urls={args.urls}
/>
);
}
return null;
});
export default Portal;
Search/index.tsx:20-46).Flexbox with height={'100%'} and structure for a side panel viewport.packages/builtin-tools/src/portals.tsimport { WebBrowsingManifest, WebBrowsingPortal } from '@lobechat/builtin-tool-web-browsing/client';
import { type BuiltinPortal } from '@lobechat/types';
export const BuiltinToolsPortals: Record<string, BuiltinPortal> = {
[WebBrowsingManifest.identifier]: WebBrowsingPortal as BuiltinPortal,
};
client/components/ — Shared SubcomponentsCross-cutting building blocks used by multiple surfaces live here, not duplicated in each surface folder.
Examples from web-browsing/src/client/components/:
CategoryAvatar.tsx — search category iconEngineAvatar.tsx — search engine logo (used in Inspector chip + Render list + Portal header)SearchBar.tsx — editable query bar (used in Render and Portal)Examples from local-system/src/client/components/:
FileItem.tsx — single file row (used in ListFiles Render, SearchFiles Render, MoveLocalFiles Render)FilePathDisplay.tsx — path with truncation (used everywhere)client/components/, exported via client/components/index.ts.client/index.ts only if other packages need them; otherwise keep internal.client/index.ts — Package Public APIRe-exports everything the registries need plus useful types/manifest:
// Inspector — required
export { TaskInspectors } from './Inspector';
// Render — only if any API has one
export { TaskRenders, CreateTaskRender, RunTasksRender } from './Render';
// Placeholder / Streaming / Intervention — only if used
export { LocalSystemListFilesPlaceholder, LocalSystemSearchFilesPlaceholder } from './Placeholder';
export { LocalSystemStreamings } from './Streaming';
export { LocalSystemInterventions } from './Intervention';
// Portal — single export per tool
export { default as WebBrowsingPortal } from './Portal';
// Reusable components if other packages need them
export { CategoryAvatar, EngineAvatar, SearchBar } from './components';
// Re-export manifest, identifier, types for convenience
export { TaskManifest, TaskIdentifier } from '../manifest';
export * from '../types';
| Symptom | Surface to check | ||
|---|---|---|---|
| No header at all on the tool call | Inspector missing from client/Inspector/index.ts registry | ||
| Header shows the API name but no chips | Inspector missing `args?.X | partialArgs?.X` fallback | |
| Header doesn't pulse during loading | Missing shinyTextStyles.shinyText on isArgumentsStreaming || isLoading | ||
| Empty result card under header | Render returned <div /> instead of null when no data | ||
| Layout jump when result arrives | Placeholder dimensions don't match Render dimensions | ||
| Approval dialog never appears | Manifest missing humanIntervention, or Intervention not in registry | ||
| Approval click doesn't wait for inline edit | Missing registerBeforeApprove(id, flushFn) | ||
| Portal opens but blank | Switch in Portal/index.tsx doesn't cover the apiName | ||
Strings show as builtins.lobe-foo.apiName.bar | Missing i18n key in src/locales/default/plugin.ts (or not seeded in dev locale files) | ||
Wrong color shade on <Text type="secondary"> | type='secondary' is lighter than colorTextSecondary — pass via style={{ color: cssVar.colorTextSecondary }} |