
Builtin Tool
- 10 installs
- 81.3k repo stars
- Updated August 5, 2026
- lobehub/lobe-chat
Helps with ai & agent building tasks during AI-assisted development.
About
builtin-tool is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- builtin-tool
- AI & Agent Building
- AI-coding skill
Builtin Tool by the numbers
- 10 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #11,959 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lobehub/lobe-chat --skill builtin-toolAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10 |
|---|---|
| repo stars | ★ 81.3k |
| Last updated | August 5, 2026 |
| Repository | lobehub/lobe-chat ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Builtin Tool Authoring Guide
A builtin tool is a package the agent runtime can call. It ships five faces:
| Face | Lives in | Audience |
|---|---|---|
| Manifest + types | src/{manifest,types,systemRole}.ts | The LLM (tool spec + system prompt) |
| ExecutionRuntime | src/ExecutionRuntime/ | Server / desktop / any runtime caller |
| Executor | src/client/executor/ | Frontend (wraps stores/services) |
| Client UI | src/client/{Inspector,Render,…}/ | Chat UI |
| Registry wiring | packages/builtin-tools/src/*.ts + src/store/tool/slices/builtin/executors/index.ts | Framework |
---
Read These First
| Question | Doc |
|---|---|
| Where do files live? What does each face do? Wiring? | architecture.md |
| How do I name the tool, design APIs, write the manifest, executor, ExecutionRuntime? | tool-design.md |
| How do I build Inspector / Render / Placeholder / Streaming / Intervention / Portal? | ui/ |
---
When to Use This Skill
- Creating a new
packages/builtin-tool-<name>/package - Adding a new API method to an existing builtin tool
- Building or restyling any of the 6 client surfaces for a tool
- Wiring a tool into the central registries
- Debugging "tool not found / API not found / render not showing / placeholder stuck" errors
---
Top-Level Design Principles
1. `lobe-<domain>` identifier is permanent. It's stored in message history. Renames need @deprecated aliases (see packages/builtin-tools/src/inspectors.ts:88-89). Get it right the first time. 2. ApiName is an `as const` object, not a TS enum. It doubles as the runtime list BaseExecutor iterates over. 3. Three result fields, three audiences:
content: string→ the LLM reads itstate: Record<…>→ the UI'spluginState; result-domain only, never echo all params backerror: { type, message, body? }→ both LLM and UI;typeis a stable code
4. Split execution from frontend wiring.
src/ExecutionRuntime/— pure runtime, no React, no Zustand, accepts services via constructor. The default place for new logic.src/client/executor/—BaseExecutorsubclass that callsExecutionRuntime(or stores/services directly when frontend-only).
5. UI defaults to "do nothing". Inspector is required (the header strip). Render/Placeholder/Streaming/Intervention/Portal are added only when there's something specific to show — empty registries are fine. 6. *Style with `createStaticStyles + cssVar.** (zero-runtime). Fall back to createStyles + token only when you genuinely need runtime values. Use @lobehub/ui components, not raw antd. 7. **i18n keys live in src/locales/default/plugin.ts.** Inspector titles must come from t('builtins.<identifier>.apiName.<api>')` so something renders while args stream.
---
Package Layout (preferred, post-2026 convention)
packages/builtin-tool-<name>/
├── package.json
└── src/
├── index.ts # exports manifest + types + systemRole + Identifier (no React, no stores)
├── manifest.ts # BuiltinToolManifest with JSON Schema for every API
├── types.ts # ApiName const + Params/State interfaces per API
├── systemRole.ts # System prompt teaching the model when/how to use the APIs
├── ExecutionRuntime/ # ✅ Default home for runtime logic (server- or anywhere-callable)
│ └── index.ts
└── client/
├── index.ts # Re-exports for the registries
├── executor/ # ✅ Frontend executor — extends BaseExecutor, often delegates to ExecutionRuntime
│ └── index.ts
├── Inspector/ # required — header chip per API
├── Render/ # optional — rich result card
├── Placeholder/ # optional — skeleton during streaming/execution
├── Streaming/ # optional — live output renderer (e.g. RunCommand, WriteFile)
├── Intervention/ # optional — approval / edit-before-run UI
├── Portal/ # optional — full-screen detail view
└── components/ # shared subcomponents used by the surfaces aboveOlder packages (builtin-tool-task, builtin-tool-calculator, etc.) still have src/executor/ as a sibling of src/client/. That's grandfathered; don't relocate without a deliberate refactor. New packages and new APIs added to existing packages should follow the layout above.
package.json exports map:
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/client/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
}---
Authoring Checklist
Before opening the PR:
- [ ] Identifier follows
lobe-<domain>and is stable (lives in message history). - [ ] Every
<Name>ApiNamevalue has: a manifestapi[]entry, an executor method, an Inspector, an i18napiName.*key. - [ ]
Paramsinterfaces match the JSON Schema;Stateinterfaces match what the executor returns and what the UI surfaces read. - [ ] System prompt disambiguates confusable APIs and points to batch variants.
- [ ] Runtime logic lives in
ExecutionRuntime/; theclient/executor/only wires stores/services and delegates. - [ ] Executor returns
{ success, content, state, error? }via a singletoResult()funnel —contentalways non-empty (default toerror.message). - [ ] Inspector handles
isArgumentsStreaming,isLoading,partialArgs, missingpluginState. - [ ] Render returns
nulluntil it has data; only created for APIs with rich results. - [ ] Placeholder added if the API has a perceivable execution lag (search, list, crawl).
- [ ] Streaming added for APIs that emit incremental output (run command, write file, code execution).
- [ ] Intervention added if
humanInterventionis set in the manifest. - [ ] All registry files updated (see architecture.md → Registry wiring).
- [ ] i18n keys in
src/locales/default/plugin.tsplus dev seeds inen-US/zh-CN. - [ ]
bunx vitest run --silent='passed-only' 'packages/builtin-tool-<name>'passes. - [ ]
bun run type-checkpasses.
---
Reference Tools
Pick the closest neighbor and copy:
| If your tool is… | Read first |
|---|---|
| Pure-compute, no UI state | packages/builtin-tool-calculator/ — ExecutionRuntime reuses executor (mathjs/nerdamer work everywhere) |
| CRUD over a domain entity | packages/builtin-tool-task/ — full Inspector + Render set, batch variants |
| Heavy UI (Inspector/Render/Placeholder/Portal) | packages/builtin-tool-web-browsing/ — search-style result UI, Portal for detail view |
| Desktop / filesystem with all surfaces (incl. Streaming + Intervention) | packages/builtin-tool-local-system/ — ExecutionRuntime injects an ILocalSystemService, executor calls it |
| Server-side pure (no client executor) | packages/builtin-tool-web-browsing/ — only ExecutionRuntime is exported; the chat client doesn't run it |
| Needs human approval before running | packages/builtin-tool-local-system/src/client/Intervention/ — per-API approval components |
Builtin Tool Architecture
The Five Faces
A builtin tool ships five distinct faces, each compiled into a different bundle:
┌─────────────────────────────────────────────────────────────────┐
│ ./ │
│ Manifest + Types + systemRole │
│ ─ Pure data, no React, no Node-only deps. │
│ ─ Imported by: server (LLM tool spec), client (registries), │
│ anyone who needs to know "what tools exist". │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ./executionRuntime │
│ src/ExecutionRuntime/index.ts │
│ ─ Pure runtime logic. Accepts services via constructor — │
│ never imports concrete services or stores directly. │
│ ─ Imported by: server (BuiltinServerRuntimeOutput), tests, │
│ and the client executor as a delegate. │
│ ─ Returns: BuiltinServerRuntimeOutput { content, state, … } │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ./executor │
│ src/client/executor/index.ts │
│ ─ BaseExecutor subclass. Wires Zustand stores and frontend │
│ services into ExecutionRuntime, then funnels through │
│ toResult() into BuiltinToolResult { content, state, error, │
│ success }. │
│ ─ Imported by: src/store/tool/slices/builtin/executors/ │
│ index.ts (registered as a singleton). │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ ./client │
│ src/client/{Inspector,Render,Placeholder,Streaming, │
│ Intervention,Portal,components}/ │
│ ─ React 'use client' surfaces. Read args + pluginState. │
│ ─ Imported by: packages/builtin-tools/src/{inspectors, │
│ renders,placeholders,streamings,interventions,portals}.ts. │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Registry wiring │
│ packages/builtin-tools/src/*.ts │
│ src/store/tool/slices/builtin/executors/index.ts │
│ ─ Aggregator maps: identifier → { apiName → component }. │
└─────────────────────────────────────────────────────────────────┘The split exists so:
- Server bundles import only
./and./executionRuntimeand never touch React. - Frontend bundles import
./clientand never touch Node-only services. - The runtime is testable without React or Electron present.
---
Why ExecutionRuntime is the Default Home for Logic
Old pattern (grandfathered): business logic in src/executor/ directly. Examples: builtin-tool-task, older tools. Works, but the executor mixes runtime logic with frontend service plumbing — hard to reuse on the server.
New pattern (preferred): business logic in src/ExecutionRuntime/, frontend wiring in src/client/executor/. Examples: builtin-tool-local-system, builtin-tool-web-browsing, builtin-tool-calculator.
ExecutionRuntime
├─ accepts services via constructor (or `static create(opts)`)
├─ returns BuiltinServerRuntimeOutput (content + state + success)
└─ no React, no Zustand, no `@/services/...` direct imports
client/executor
├─ extends BaseExecutor<typeof <Name>ApiName>
├─ holds a `runtime = new <Name>ExecutionRuntime(realService)` instance
├─ each ApiName method:
│ 1. resolve scope / pull defaults from BuiltinToolContext
│ 2. call runtime.<method>(args)
│ 3. funnel through toResult() → BuiltinToolResult
└─ exported singleton: export const <name>Executor = new <Name>Executor()Service injection
ExecutionRuntime should declare a TypeScript interface for the services it needs and accept the implementation via constructor. Server callers wire in real implementations; tests wire in mocks. Example from local-system:
export interface ILocalSystemService {
readLocalFile: (params: any) => Promise<any>;
writeFile: (params: any) => Promise<any>;
/* … */
}
export class LocalSystemExecutionRuntime extends ComputerRuntime {
constructor(private service: ILocalSystemService) {
super();
}
/* methods delegate to this.service.* */
}The client/executor instantiates it once with the real service:
import { localFileService } from '@/services/electron/localFileService';
import { LocalSystemExecutionRuntime } from '../../ExecutionRuntime';
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
private runtime = new LocalSystemExecutionRuntime(localFileService);
/* … */
}When ExecutionRuntime is the only thing you ship
Some tools are server-only — there's no frontend executor. builtin-tool-web-browsing is the canonical example: only ./ and ./executionRuntime are exported, no ./executor, and the runtime is constructed by the server-side ToolExecutionService. Skip client/executor/ entirely for those.
When the executor reuses the runtime as-is
Pure-compute tools (builtin-tool-calculator) often have an executor whose ApiName methods call executor.calculate(args) and an ExecutionRuntime whose methods call calculatorExecutor.calculate(args) — same logic, two thin wrappers. That's fine; the duplication buys you the bundle split.
---
The Result Contract
BuiltinServerRuntimeOutput (what ExecutionRuntime returns)
{
content: string; // the LLM-facing text — never undefined; default to error message
state?: any; // result-domain object the UI reads as pluginState
success: boolean; // mandatory
error?: any; // raw error; the executor will repackage
}BuiltinToolResult (what the executor returns to the runtime)
{
success: boolean;
content?: string;
state?: any;
error?: { type: string; message: string; body?: any };
metadata?: Record<string, any>; // rare; e.g. { agentCouncil: true }
stop?: boolean; // rare; halt the orchestration step
}The toResult funnel (mandatory)
Every executor method returns through a single toResult() to enforce two invariants:
1. `content` is never undefined. A missing content collapses downstream into '', leaving the Debug pane blank while pluginState was already saved. See the globLocalFiles regression in local-system/src/client/executor/index.ts:60-84. 2. `state` survives failures. Renderers can keep showing partial output even when success: false.
private toResult(output: BuiltinServerRuntimeOutput): BuiltinToolResult {
const errorMessage = typeof output.error?.message === 'string' ? output.error.message : undefined;
const safeContent = output.content || errorMessage || 'Tool execution failed';
if (!output.success) {
return {
success: false,
content: safeContent,
state: output.state,
error: output.error
? { type: 'PluginServerError', message: errorMessage ?? safeContent, body: output.error }
: undefined,
};
}
return { success: true, content: safeContent, state: output.state };
}---
BaseExecutor — How Method Dispatch Works
BaseExecutor.invoke(apiName, params, ctx) does:
if (!this.hasApi(apiName)) return { error: { type: 'ApiNotFound', … }, success: false };
return (this as any)[apiName](params, ctx); // method name MUST equal apiName valueSo:
- Method names must equal `<Name>ApiName` values, exactly. A typo silently routes to "ApiNotFound".
- Methods must be class fields, not class methods, because
thisis lost when registry callsexecutor.invoke(apiName, params, ctx). Always declare asmethodName = async (…) => { … }. - Always destructure `apiEnum` and `identifier` as `readonly` instance fields, not getters —
BaseExecutor.hasApi/getApiNamesreads them synchronously.
---
BuiltinToolContext — What the Executor Receives
The runtime hands every executor method an optional BuiltinToolContext as the second argument:
| Field | Use |
|---|---|
agentId | Default agent for "current agent" semantics (e.g. listTasks) |
groupId | Group chat scope |
topicId | Current topic — needed when creating messages/operations |
taskId | Current task identifier — fallback for "implicit" param |
documentId | Current page/document scope |
messageId | The tool message being created (for state attachments) |
sourceMessageId | The user message that triggered this tool turn |
operationId | Operation lineage (use for cancellation, tracing) |
scope | `'task' \ |
signal: AbortSignal | Honor for long-running ops |
stepContext | Cross-message runtime state (lobe-agent todos, etc.) |
registerAfterCompletion(cb) | Defer side-effects past message-update race |
groupOrchestration | Group orchestration callbacks |
Use rule: read with ?., fall back to explicit params, never silently override an explicit param with a context value.
---
i18n Integration
Source of truth: src/locales/default/plugin.ts. Keys follow builtins.<identifier>.<topic>.<…>:
| Key | Use |
|---|---|
builtins.<identifier>.title | Display title (overrides manifest.meta.title when present) |
builtins.<identifier>.apiName.<api> | Inspector header label (one per ApiName) |
builtins.<identifier>.inspector.<…> | Extra Inspector strings ("no results", chips, counters) |
builtins.<identifier>.<feature>.<…> | Render / Intervention strings, free-form per tool |
For dev preview, also seed locales/zh-CN/plugin.json and locales/en-US/plugin.json. Run pnpm i18n before opening a PR — it's slow, so do it once at the end. (See the i18n skill for the full workflow.)
---
Registry Wiring
Five core files plus optional ones. Miss any and you'll see "tool not found", a missing chip, a blank result card, a stuck spinner, or an approval dialog that never appears.
| File | Add what |
|---|---|
| Required | |
packages/builtin-tools/src/index.ts | Import <Name>Manifest; push entry to builtinTools. Set hidden/discoverable flags. |
packages/builtin-tools/src/identifiers.ts | Add <Name>Manifest.identifier to builtinToolIdentifiers. |
packages/builtin-tools/src/inspectors.ts | Import <Name>Inspectors, <Name>Manifest; add to BuiltinToolInspectors. |
src/store/tool/slices/builtin/executors/index.ts | Import <name>Executor; add to registerExecutors([…]). |
| Conditional — add only if the surface exists | |
packages/builtin-tools/src/renders.ts | Add to BuiltinToolsRenders if any API has a Render. |
packages/builtin-tools/src/placeholders.ts | Add to BuiltinToolPlaceholders if any API has a Placeholder. |
packages/builtin-tools/src/streamings.ts | Add to BuiltinToolStreamings if any API has a Streaming renderer. |
packages/builtin-tools/src/interventions.ts | Add to BuiltinToolInterventions if any API has an Intervention component. |
packages/builtin-tools/src/portals.ts | Add to BuiltinToolsPortals if the tool has a Portal. |
packages/builtin-tools/src/displayControls.ts | Add if Render must show/hide based on result content (rare; see ClaudeCode/Codex). |
Optional flags in packages/builtin-tools/src/index.ts
{
identifier: TaskManifest.identifier,
manifest: TaskManifest,
type: 'builtin',
hidden: true, // hide from chat-input Tools popover
discoverable: false, // exclude from agent builder / skill discovery
}Lists in the same file you may need to touch:
defaultToolIds— added to the agent's tool list by defaultalwaysOnToolIds— forced on regardless of user selection (use sparingly)runtimeManagedToolIds— enable state controlled by runtime, not user UI; must mirror the rules map inapps/server/src/modules/Mecha/AgentToolsEngine/index.tsandsrc/helpers/toolEngineering/index.ts
---
File-Map at a Glance
packages/builtin-tool-<name>/
├── package.json # exports: ., ./client, ./executor, ./executionRuntime
└── src/
├── index.ts # export Manifest, Identifier, types, systemPrompt
├── manifest.ts # BuiltinToolManifest + Identifier const
├── types.ts # ApiName + Params/State per API
├── systemRole.ts # System prompt (multiple variants OK: systemRole.desktop.ts)
├── ExecutionRuntime/
│ └── index.ts # <Name>ExecutionRuntime — pure runtime, service injection
└── client/
├── index.ts # exports for the registries
├── executor/
│ └── index.ts # <Name>Executor extends BaseExecutor; export <name>Executor
├── Inspector/
│ ├── index.ts # <Name>Inspectors record
│ └── <ApiName>/index.tsx # one folder per API (or .tsx file when trivial)
├── Render/
│ ├── index.ts # <Name>Renders record
│ └── <ApiName>/ # rich renders → folder with subcomponents
├── Placeholder/
│ ├── index.ts
│ └── <ApiName>.tsx # usually a single skeleton file
├── Streaming/
│ ├── index.ts
│ └── <ApiName>/ # live-output renderer
├── Intervention/
│ ├── index.ts
│ └── <ApiName>/ # approval / edit-before-run UI
├── Portal/
│ ├── index.tsx # routing component (switch on apiName)
│ └── <ApiName>/ # full-screen detail view
└── components/ # FileItem, EngineAvatar, etc. — shared subcomponentsSkip every client/<surface>/ directory you don't need — empty registries are fine.
Tool Design (Naming, Manifest, Executor, Runtime)
This doc covers everything that isn't UI: the tool's identifier, API surface, manifest, types, system prompt, ExecutionRuntime, and the executor that wires it into the frontend.
For UI surfaces (Inspector / Render / Placeholder / Streaming / Intervention / Portal), see ui/. For where files live and how registries work, see architecture.md.
---
1. Naming
| Thing | Convention | Example |
|---|---|---|
| Package directory | packages/builtin-tool-<kebab>/ | builtin-tool-task |
| npm name | @lobechat/builtin-tool-<kebab> | @lobechat/builtin-tool-task |
Tool identifier | lobe-<kebab-domain> — persisted in message history | lobe-task, lobe-calculator, lobe-knowledge-base |
| Identifier const | <Name>Identifier exported from manifest.ts (or types.ts) | export const TaskIdentifier = 'lobe-task' |
| API name const | <Name>ApiName — as const object, camelCase verbs | createTask, listTasks, runTask |
| Executor class | <Name>Executor extends BaseExecutor<typeof <Name>ApiName> | TaskExecutor |
| Executor singleton | <name>Executor (camelCase) | export const taskExecutor = new TaskExecutor() |
| ExecutionRuntime class | <Name>ExecutionRuntime | LocalSystemExecutionRuntime, WebBrowsingExecutionRuntime |
| Inspector / Render etc. | <ApiName>Inspector / <ApiName>Render | CreateTaskInspector, SearchInspector |
Identifier rules
- `lobe-` prefix is mandatory — many switches in the codebase key off it.
- Pick a domain noun, not a verb (
lobe-task, notlobe-task-manager). - The identifier is persisted in message history — renaming after release means the
@deprecatedalias trick (register the legacy identifier as a second key ininspectors.ts/renders.tspointing at the new module). Get it right the first time.
ApiName rules
- Verb + noun, camelCase:
createTask,viewTask,runTasks. - Plural variant for batch (
createTasks,runTasks) — describe in the manifest description that it's preferred over multiple single calls. The system prompt should also push the batch form. - Reserve clear separation between mutating verbs (
updateTaskStatus,editTask) and execution verbs (runTask). The system prompt must warn the model when these are confusable — seetaskfor the canonical "do NOT use updateTaskStatus(running) to start a task" warning. - Read-only verbs:
list*,view*,get*,search*. Mutating:create*,edit*,update*,delete*. Triggers/effects:run*,execute*,submit*.
---
2. types.ts — ApiName + Params/State
Define <Name>ApiName as as const so it doubles as a runtime enum (used by BaseExecutor) and a literal type. Then declare Params and State per API.
export const TaskIdentifier = 'lobe-task';
export const TaskApiName = {
createTask: 'createTask',
createTasks: 'createTasks',
listTasks: 'listTasks',
/* …one entry per API, group logically (CRUD then run-style) */
} as const;
export type TaskApiNameType = (typeof TaskApiName)[keyof typeof TaskApiName];
// One block per API
export interface CreateTaskParams {
name: string;
instruction: string; /* … */
}
export interface CreateTaskState {
identifier?: string;
success: boolean;
}
export interface CreateTasksParams {
tasks: CreateTaskParams[];
}
export interface CreateTasksItemResult {
error?: string;
identifier?: string;
name: string;
success: boolean;
}
export interface CreateTasksState {
failed: number;
results: CreateTasksItemResult[];
succeeded: number;
}The result-domain rule for `State` (memory: "pluginState is result-domain, not call-domain"):
- Include only fields the UI renders after the call returns — ids the LLM didn't have when calling, counts, summary numbers, server-assigned status.
- Don't echo all params. The Inspector/Render gets
argsfor free. - Keep batch results as
{ succeeded, failed, results }so the Render can show a one-line summary plus a detail list.
---
3. manifest.ts — JSON Schema for the LLM
import type { BuiltinToolManifest } from '@lobechat/types';
import { systemPrompt } from './systemRole';
import { TaskApiName, TaskIdentifier } from './types';
export const TaskManifest: BuiltinToolManifest = {
identifier: TaskIdentifier,
type: 'builtin',
systemRole: systemPrompt,
meta: {
avatar: '📋',
title: 'Task Tools',
description: 'Create, list, edit, delete tasks with dependencies',
readme: 'Optional long description shown in tool detail pages',
},
api: [
{
name: TaskApiName.createTask,
description:
'Create a new task. Optionally attach as a subtask via parentIdentifier. ' +
'Prefer createTasks when planning a batch.',
parameters: {
type: 'object',
required: ['name', 'instruction'],
properties: {
name: { type: 'string', description: 'Short, descriptive name.' },
instruction: {
type: 'string',
description: 'Detailed instruction for what the task should accomplish.',
},
parentIdentifier: {
type: 'string',
description:
'Identifier of the parent task (e.g. "TASK-1"). If provided, the new task becomes a subtask.',
},
priority: {
type: 'number',
description: 'Priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low. Default is 0.',
},
},
},
},
/* …one entry per ApiName */
],
};Manifest writing checklist
- Every API in `<Name>ApiName` has exactly one entry in `api[]`. Easy to drift after a refactor.
- `description` on each API is the model's only docs. Make it long enough for the LLM to pick the right tool. Mention edge cases ("If you provide any filter, omitted filters are not applied implicitly"), defaults, and the relationship to sibling APIs ("To START a task, use runTask — updateTaskStatus only flips a flag").
- `parameters` is JSON Schema (
LobeChatPluginApi). Useenum,required,items,oneOf,additionalProperties: falseetc. — these survive into the LLM's tool spec. - Use `additionalProperties: false` on parameter objects so the model can't sneak unknown fields past validation.
- Number parameters with semantic values (
priority: 0=none, 1=urgent, …) should describe the mapping in the description. Don't rely onenumalone for numbers — the model often fills the wrong one. - `enum` arrays for known string sets (statuses, categories, engines). Spread from a constants module (
enum: [...TASK_STATUSES]) so the manifest stays in sync.
Optional manifest fields
{
/* Where this tool can run.
'client' → Agent Gateway dispatches to the desktop client (filesystem, Electron only)
'server' → ToolExecutionService runs it on the server
omitted → server only */
executors: ['client', 'server'],
/* Default human intervention policy for all APIs that don't specify one.
Pair with an Intervention component (see ui/intervention.md). */
humanIntervention: 'never' | 'always' | { /* extended config */ },
}Per-API humanIntervention and renderDisplayControl go inside each api[] entry.
---
4. systemRole.ts — Operator Instructions for the Model
This is appended to the agent system prompt whenever the tool is enabled. Treat it as a how-to-use guide for the LLM, not marketing copy.
export const systemPrompt = `You have access to Task management tools. Use them to:
- **createTask**: Create a new task. Use parentIdentifier to make it a subtask.
- **createTasks**: Prefer this over multiple createTask calls when planning a batch
(e.g. all subtasks under one parent, or all chapters of an outline).
- **runTask**: Actually START a task — kicks off the agent in a new (or continued)
topic. Do NOT use updateTaskStatus(running) to start a task; that only flips a
flag without executing. The task must have an assigneeAgentId.
- **updateTaskStatus**: Change a task's status (completed/cancelled/paused/failed).
If you mark a task as failed, include an error message explaining why.
- ...
When planning work:
1. Create tasks for each major piece (use parentIdentifier to organize as subtasks).
2. Use editTask with addDependencies to control execution order.
3. Use updateTaskStatus to mark the current task completed when done.`;Patterns that work well
- Bulleted list, bold the API name, one line per API. The model picks tools by skimming.
- Disambiguate confusable APIs explicitly (
runTaskvsupdateTaskStatus). - Push toward batched APIs ("Prefer this when…").
- End with a numbered workflow if the tool has a typical sequence.
- For tools with multiple environments (e.g. desktop vs cloud), keep variants in
systemRole.tsandsystemRole.desktop.tsand pick at the manifest level. Seebuiltin-tool-local-system.
Dynamic system prompts
If the prompt depends on runtime state (current date, available models), export a function and call it in the manifest:
// systemRole.ts
export const systemPrompt = (today: string) => `Today is ${today}. You have web search tools…`;
// manifest.ts
import dayjs from 'dayjs';
systemRole: systemPrompt(dayjs(new Date()).format('YYYY-MM-DD')),---
5. ExecutionRuntime/index.ts — Pure Runtime
This is the default home for new tool logic going forward. The runtime is a class that:
- Has no React, no Zustand, no
@/services/...direct imports. - Receives services as constructor injection (or as method args).
- Returns
BuiltinServerRuntimeOutputfrom each method. - Is unit-testable by passing in mocks.
Pattern A: Inject a service interface
Use when the runtime calls out to IPC, network, or DB.
// ExecutionRuntime/index.ts
import type { BuiltinServerRuntimeOutput } from '@lobechat/types';
export interface IWebBrowsingService {
search: (q: SearchQuery) => Promise<UniformSearchResponse>;
crawlPages: (urls: string[]) => Promise<CrawlResults>;
}
export interface WebBrowsingRuntimeOptions {
searchService: IWebBrowsingService;
documentService?: WebBrowsingDocumentService;
agentId?: string;
topicId?: string;
}
export class WebBrowsingExecutionRuntime {
constructor(private opts: WebBrowsingRuntimeOptions) {}
async search(
args: SearchQuery,
options?: { signal?: AbortSignal },
): Promise<BuiltinServerRuntimeOutput> {
try {
const data = await this.opts.searchService.search(args, options);
if (data.errorDetail) {
return {
success: false,
content: data.errorDetail,
error: { message: data.errorDetail },
state: data,
};
}
return {
success: true,
content: searchResultsPrompt(data.results.slice(0, 10)),
state: data,
};
} catch (e) {
return { success: false, content: (e as Error).message, error: e };
}
}
}Pattern B: Reuse the executor
Use when the same logic runs in browser and Node (e.g. mathjs, nerdamer). The runtime is a thin wrapper that imports the executor and re-types the state per API. See builtin-tool-calculator/src/ExecutionRuntime/index.ts for the canonical example.
Pattern C: Extend a shared base
When you're implementing a domain that already has a base runtime (file ops via ComputerRuntime), extend and only override callService + result normalization. See builtin-tool-local-system/src/ExecutionRuntime/index.ts.
Runtime contract
Every method returns:
{
content: string; // LLM-facing — never undefined; default to error message
state?: any; // result-domain — what the UI's pluginState becomes
success: boolean; // mandatory
error?: any; // raw error object; the executor will repackage
}Use @lobechat/prompts formatters (searchResultsPrompt, crawlResultsPrompt, formatTaskCreated, etc.) to produce structured content. They emit XML/markdown that's already tuned for token efficiency.
---
6. client/executor/index.ts — Frontend Wiring
The executor's job is to resolve frontend defaults (current agent, current task, scope) and call the runtime. It then funnels through toResult() into the BuiltinToolResult shape.
import { BaseExecutor, type BuiltinToolContext, type BuiltinToolResult } from '@lobechat/types';
import debug from 'debug';
import { taskService } from '@/services/task';
import { getTaskStoreState } from '@/store/task';
import { TaskIdentifier } from '../../manifest';
import { TaskApiName, type CreateTaskParams } from '../../types';
const log = debug('lobe-task:executor');
class TaskExecutor extends BaseExecutor<typeof TaskApiName> {
readonly identifier = TaskIdentifier;
protected readonly apiEnum = TaskApiName;
// ⚠ class FIELD, not a method — preserves `this` when invoked via registry
createTask = async (
params: CreateTaskParams,
ctx?: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
try {
log('createTask params=%o', params);
const task = await getTaskStoreState().createTask({
name: params.name,
instruction: params.instruction,
// Default assignee from context — never silently override an explicit value
assigneeAgentId:
params.assigneeAgentId ?? (ctx?.scope === 'task' ? undefined : ctx?.agentId),
parentTaskId: params.parentIdentifier?.trim() || undefined,
priority: params.priority,
});
if (!task) return this.errorResult('Failed to create task', 'CreateFailed');
return {
success: true,
content: formatTaskCreated({ identifier: task.identifier, name: task.name /* … */ }),
state: { identifier: task.identifier, success: true },
};
} catch (error) {
return this.errorResult(error, 'CreateTaskFailed');
}
};
private errorResult(err: unknown, type: string): BuiltinToolResult {
const message = err instanceof Error ? err.message : String(err) || 'Unknown error';
return { success: false, content: `Failed: ${message}`, error: { type, message } };
}
}
export const taskExecutor = new TaskExecutor();Hard rules
1. Methods are class fields (name = async (…) => {…}), not class methods. The registry calls (executor as any)[apiName](params, ctx); arrow-function fields keep this bound. 2. `identifier` and `apiEnum` are `readonly` instance fields, not getters — BaseExecutor.hasApi/getApiNames reads them synchronously at registration time. 3. Default missing params from `ctx`, but never silently override explicit values. Use params.foo ?? ctx?.foo, not ctx?.foo ?? params.foo. 4. One funnel for all returns. Either always return through toResult(runtime.x()) (when delegating) or through errorResult(…) for the catch arm. Never inline { success: false, content: '' } — content: '' collapses the Debug pane to blank. 5. `debug('lobe-<name>:executor')`. Match the namespace to the identifier minus lobe- when convenient. 6. Singleton export. export const <name>Executor = new <Name>Executor() — the registry imports the instance, not the class.
When the executor delegates to ExecutionRuntime
class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
readonly identifier = LocalSystemIdentifier;
protected readonly apiEnum = LocalSystemApiEnum;
private runtime = new LocalSystemExecutionRuntime(localFileService);
readLocalFile = async (params: LocalReadFileParams): Promise<BuiltinToolResult> => {
try {
const result = await this.runtime.readFile({
path: params.path,
startLine: params.loc?.[0],
endLine: params.loc?.[1],
});
return this.toResult(result);
} catch (error) {
return this.errorResult(error);
}
};
private toResult(out: BuiltinServerRuntimeOutput): BuiltinToolResult {
const errMsg = typeof out.error?.message === 'string' ? out.error.message : undefined;
const safe = out.content || errMsg || 'Tool execution failed';
if (!out.success) {
return {
success: false,
content: safe,
state: out.state, // ← preserve partial state on failure
error: out.error
? { type: 'PluginServerError', message: errMsg ?? safe, body: out.error }
: undefined,
};
}
return { success: true, content: safe, state: out.state };
}
}The toResult funnel is mandatory: it enforces never-undefined content and partial-state preservation. Both invariants caught real production bugs (globLocalFiles Response empty, editLocalFile partial state lost).
---
7. index.ts — Package Entry Point
Keep it pure data + the manifest. No React, no stores, no Node-only imports.
export { TaskIdentifier, TaskManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
TaskApiName,
type TaskApiNameType,
type CreateTaskParams,
type CreateTaskState,
/* …all Params/State types */
} from './types';
// Optional helpers used by both the runtime and the UI
export { TASK_STATUSES, UNFINISHED_TASK_STATUSES } from './constants';This entry is what packages/builtin-tools/src/index.ts and identifiers.ts import — it must be importable from server bundles.
---
8. package.json
{
"dependencies": {
"@lobechat/prompts": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"
},
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./executor": "./src/client/executor/index.ts",
"./executionRuntime": "./src/ExecutionRuntime/index.ts"
},
"main": "./src/index.ts",
"name": "@lobechat/builtin-tool-<name>",
"peerDependencies": {
"@lobehub/ui": "^5",
"antd": "^6",
"antd-style": "*",
"lucide-react": "*",
"react": "*",
"react-i18next": "*"
},
"private": true,
"version": "1.0.0"
}Why peer not direct deps for client libs: the ./ and ./executionRuntime entry points must be importable from server code. Listing React etc. as peer deps prevents bundlers from following them when only the runtime is consumed.
Skip `./executor` if the package has no frontend executor (server-only tools like builtin-tool-web-browsing).
---
9. Common Pitfalls
| Symptom | Likely cause |
|---|---|
| "ApiNotFound" at runtime | Method name in executor doesn't match ApiName value (typo, wrong case) |
| Method works once, then "this is undefined" | Method declared as async fn() {} instead of fn = async () => {} — this lost when registry invokes |
Debug "Response" pane blank but pluginState populated | Returning content: '' or letting output.content be undefined — use the toResult funnel |
| Partial result vanishes on failure | toResult discarded state when success: false; preserve it |
| Tool shows up but doesn't run on desktop | executors in manifest doesn't include 'client' (or vice versa for server-only) |
| Same tool registered twice / legacy identifier ghost | Identifier collision; check @deprecated aliases in inspectors.ts/renders.ts |
| Manifest test fails after adding API | Forgot to add the corresponding i18n apiName.<api> key |
TypeScript error on BaseExecutor<typeof X> | X declared with enum instead of as const object — must be the const-object form |
Composition — Shared Components & Package API
client/components/ — Shared Subcomponents
Cross-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)
Rules
- Live under
client/components/, exported viaclient/components/index.ts. - Re-export from
client/index.tsonly if other packages need them; otherwise keep internal. - Keep them dumb — props in, JSX out, no store reads. The store reads belong in the surface that composes them.
---
client/index.ts — Package Public API
Re-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';Diagnostic Quick-Lookup
| 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 \ |
| Header doesn't pulse during loading | Missing shinyTextStyles.shinyText on `isArgumentsStreaming \ |
| Empty result card under header | Render returned <div /> instead of null when no data |
| Render looks "complex" / card-in-card | Filled container (colorFillQuaternary) wrapping more filled boxes — flatten to single-layer, see shared-rules.md |
| 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 }} |
Inspector — Header Chip (required)
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.
Props (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 };
}State machine
| 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) |
Canonical example — Search
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;Inspector rules
- Wrap the whole row with
inspectorTextStyles.root(provides correct flex / line-height baseline). - Pulse with
shinyTextStyles.shinyTextwheneverisArgumentsStreaming || isLoading. - Show the i18n title first so the row is non-empty during the earliest streaming phase.
- Read both
args?.XandpartialArgs?.Xtogether —argsis final,partialArgsis in-stream. - Use chips/tags for distinct facets (identifier, name, parent, status, count). Each chip should clip with
text-overflow: ellipsisand have amax-widthso long values don't blow out the chat bubble. - Append
pluginState-derived suffixes only after loading finishes — count or "(no results)" should not appear while still searching. - Switch copy by phase. If the verb implies an ongoing action ("Creating", "Searching", "Listing"), define
<api>.loadingand<api>.completedkeys and select viaisArgumentsStreaming || isLoading ? loadingKey : completedKey. Inspector chips persist in chat history — leaving "Creating task" frozen on a finished call reads as if the tool is still running. Read-only labels that are already noun-form ("View task") can keep a single key. SeeCallSubAgentInspectorfor the canonical two-key pattern.
Inspector registry — client/Inspector/index.ts
import 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 */Intervention — Approval / Edit-Before-Run (optional)
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.
Props (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;
}Canonical example — RunCommand Intervention
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;Intervention rules
- Show a preview, not a form by default. Editing UI is opt-in via
onArgsChangeand is usually inline (click to edit a code block, etc.). - For args with debounced edit state (text fields), use
registerBeforeApprove(id, flushFn)so the approve action waits for the debounce to flush. Always return the cleanup function. - Call
onInteractionAction({ type: 'submit', payload })when the user approves;'skip'if they skip with a reason;'cancel'if they cancel the whole turn. - Add a corresponding
interventionAudit.tsin the package root if the tool needs scope/path validation before approval (seelocal-system/src/interventionAudit.ts).
Intervention registry — client/Intervention/index.ts
import { 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 */
};Placeholder — Skeleton Between Args and Result (optional)
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).
Props (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.
Canonical example — Search Placeholder
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>
);
});Placeholder rules
- Mirror the eventual Render's layout. When the result arrives the Placeholder unmounts and the Render mounts; if they share dimensions, the chat doesn't jump.
- Use
Skeleton.Block/Skeleton.Buttonfrom@lobehub/uifor placeholder shapes. - Embed any args you have (e.g. the query text) — context helps the user know what's loading.
- Pulse with
shinyTextStyles.shinyTextif the Placeholder includes literal text.
Placeholder registry — client/Placeholder/index.ts
import { 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 };Portal — Full-Screen Detail View (optional)
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.
Props (BuiltinPortalProps<Args, State>)
interface BuiltinPortalProps<Arguments = Record<string, any>, State = any> {
apiName?: string;
arguments: Arguments;
identifier: string;
messageId: string;
state: State;
}Canonical example — Web-Browsing Portal
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;Portal rules
- One Portal per tool — the file is the routing layer, subcomponents implement each API's view.
- Portals can read the chat store directly to detect "still streaming" and render a Skeleton internally (see
Search/index.tsx:20-46). - Layout assumes more space than the Render — use
Flexboxwithheight={'100%'}and structure for a side panel viewport.
Portal registry — packages/builtin-tools/src/portals.ts
import { 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,
};Tool Render 设计原则(中文草案)
这些原则用于判断一个 builtin tool 的 Inspector / Render / Placeholder / Streaming / Intervention / Portal 应该做什么,以及做到什么程度。
1. 先保证折叠态可读。 每个 API 都必须有 Inspector;用户不展开也应该能看懂 “正在做什么 / 对什么做 / 当前结果是什么”。Inspector 不应该只展示函数名和原始参数。 2. Inspector 是一句话,不是详情页。 优先表达动作、关键对象、数量、状态,例如 “分析图片 3 张”“搜索 12 个结果”“读取 config.json”。长文本、列表和结构化结果放到 Render 或 Portal。 3. Inspector 要覆盖执行生命周期。 args 还在 streaming、工具执行中、执行完成、执行失败时都应该有稳定展示;必要时同时读取 args、partialArgs 和 pluginState,避免出现空白、跳变或只显示半截参数。 4. 文案要随状态切换时态。 同一个动作在 loading 与 completed 两个阶段必须用不同的措辞:执行中用现在进行时(“正在创建任务 / Creating task / 正在搜索”),执行完成后切到完成态(“已创建任务 / Task created / 已找到 N 条”)。Inspector chip 会一直留在聊天记录里 —— 如果一直挂着 “正在 xxx”,几小时后回看历史时会读起来像还在跑。约定的 i18n 形式是 <api>.loading / <api>.completed 一对键(见 lobe-agent.apiName.callSubAgent.{loading,completed} 与 lobe-claude-code.task.{create,list,update,get}.{loading,completed}),渲染时按 isArgumentsStreaming || isLoading 决定取哪一个。只读 / 查询类(“查看任务” 这种本来就是名词性的)可以共用一个键。 5. 只有结构化结果才需要 Render。 如果工具结果只是自然语言总结,通常不需要 Render;如果结果包含列表、媒体、文件、表格、代码、diff、地图、时间线、权限请求等结构,就应该提供 Render。 6. Render 要帮助用户检查结果,而不是复述参数。 Render 的主体应该围绕工具产物组织:可预览、可比较、可筛选、可定位。参数只作为上下文辅助出现,不要把 Render 做成一块更大的 args dump。 7. 参数和结果要一起参与渲染。 好的 Tool UI 通常同时用 args 解释意图,用 pluginState 展示真实执行结果;但 pluginState 只放结果域数据,不要反向塞入可以从 args 推导出的内容。 8. 慢操作要有 Placeholder。 如果工具通常需要等待网络、文件系统、模型或外部进程,Placeholder 应该先占住最终 Render 的版式,让用户知道即将看到什么,而不是只显示一个泛化 loading。 9. Streaming 只用于连续产物。 搜索列表、日志、长文本、文件分析、分阶段计划适合 Streaming;一次性小结果不需要强行做 Streaming。Streaming UI 要能渐进追加,并且完成后自然过渡到最终 Render。 10. 有风险的动作必须 Intervention。 写文件、删除、发送、安装、执行命令、外部可见操作、权限敏感操作,都应该在执行前给出可理解的确认界面;确认文案要说明影响范围,而不是只问 “是否继续”。 11. 错误、空态和截断都是正式状态。 Render 不能在失败、无结果、超长结果时退化成空白。错误要说明发生在哪一步;空态要告诉用户没有产物;超长内容要明确 “展示前 N 项 / 还有 N 项”。 12. 信息密度要克制。 默认展示最有判断价值的部分:标题、来源、状态、摘要、少量关键字段。大对象、长列表、原文、调试数据放进可展开区域或 Portal,避免把聊天流撑成后台管理页。 13. 视觉上融入聊天流。 Tool UI 应该使用 @lobehub/ui / base-ui、Flexbox、createStaticStyles 和 cssVar.*,遵循现有间距、圆角、颜色、字号;不要为单个工具发明一套独立视觉语言。具体的样式约定见 shared-rules.md。 14. Devtools fixture 是验收入口。 新增或修改 Tool UI 时,应在 /devtools 里准备覆盖典型态、loading/streaming、空态、错误态、长内容态的 fixture;一个 API 如果在真实聊天里会出现,就不应该在 devtools 中缺席。 15. 先做用户会看的 UI,再做调试 UI。 Raw JSON、trace、schema、内部 id 可以存在,但应默认收起或放到调试区;主界面先回答用户最关心的问题:工具做了什么,结果值不值得信任,下一步能做什么。
Tool UI Surfaces
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.
---
Files in this folder
Read principles and shared-rules first — they apply to every surface. Then jump to the surface you're building.
| File | What it covers |
|---|---|
| principles.md | Design principles — when each surface exists and how far to take it |
| shared-rules.md | Cross-surface rules: component skeleton, styling, single-layer surfaces |
| inspector.md | Inspector — header chip (required) |
| render.md | Render — rich result card |
| placeholder.md | Placeholder — skeleton between args and result |
| streaming.md | Streaming — live output during execution |
| intervention.md | Intervention — approval / edit-before-run |
| portal.md | Portal — full-screen detail view |
| composition.md | Shared subcomponents (client/components/) + package public API |
| diagnostics.md | Symptom → surface quick-lookup |
Render — Rich Result Card (optional)
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.
Props (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;
}Two patterns
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 listUse Pattern B when the Render has internal state (editing mode, expanded items), error variants, or is large enough to benefit from splitting.
Error handling in Render
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>}
/>
);
}Render rules
- Return `null` if there's nothing useful to draw yet (avoids empty cards during stream).
- Use
pluginStatefor server-truth (ids, counts, server-assigned status) andargsfor what the LLM asked. Combine — neither alone is enough. - For lists, summarize with a header line and show top N items with a "+N more" tail rather than rendering everything.
- Keep the Render single-layer — the tool card is already your surface, so don't open with your own filled container and then nest more filled boxes inside it. See shared-rules.md → "Stay single-layer".
- For modals from a Render, use
@lobehub/ui/base-ui(createModal,useModalContext,confirmModal) — see the modal skill.
Render registry — client/Render/index.ts
import 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';Render display control (rare)
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.
Shared Style Rules
These apply across every surface.
The component skeleton
Every surface file is the same shape, so internalize it once instead of re-deriving it per rule. The skeleton below bakes in five mechanical conventions — copy it and fill the body:
'use client'; // (a) leaves of the chat tree must not block server rendering
import type { BuiltinInspectorProps, SearchQuery, UniformSearchResponse } from '@lobechat/types';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
// (b) type with BuiltinXProps<Args, State> — never widen to `any`.
// Args = the JSON Schema params, State = the executor's `state` field;
// they should match <Name>Params / <Name>State from types.ts.
export const SearchInspector = memo<BuiltinInspectorProps<SearchQuery, UniformSearchResponse>>(
({ args, pluginState }) => {
const { t } = useTranslation('plugin'); // (c) all strings from the `plugin` namespace
// (d) cross-cutting state (loading, streaming buffer) comes from the store,
// not props — props only carry args/state/messageId.
// const buffer = useChatStore((s) => chatToolSelectors.streamingBuffer(messageId)(s));
return <span>{t('builtins.<identifier>.apiName.search')}</span>;
},
);
SearchInspector.displayName = 'SearchInspector'; // (e) always memo + displayName
export default SearchInspector;- (c) Default an Inspector to
t('builtins.<identifier>.apiName.<api>')so the row is non-empty while args stream in. - (d) Read the store via Zustand selectors inside the component; see streaming.md for the buffer selector.
Styling: createStaticStyles + cssVar.*, @lobehub/ui over antd
Zero-runtime CSS-in-JS — 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 + tokenonly when you need runtime token computation (rare). Inlinestyle={{ color: cssVar.colorTextSecondary }}is fine for one-off dynamic values. - Components come from
@lobehub/ui(Block,Text,Flexbox,Highlighter,Alert,Tooltip,Skeleton), not rawantd. Modals come from@lobehub/ui/base-ui(createModal,useModalContext,confirmModal) — see the modal skill. - Note:
<Text type='secondary'>is a lighter shade thancolorTextSecondary. For that exact token color, write<Text style={{ color: cssVar.colorTextSecondary }}>.
Stay single-layer — don't nest filled cards
The framework already wraps every Render / Intervention in a tool card, so that card is your surface. A Render that opens with its own background: ${cssVar.colorFillQuaternary} container is already one card deep; put another filled box inside it (colorBgContainer / colorFillTertiary) and you get the card-in-card look that reads as "complex" — two or three stacked fills for what is really a flat list of fields.
- The outermost wrapper carries no fill. Use a flat container with only
padding-block: 4pxfor breathing room; let the tool card provide the card. (SeeAgent/index.tsx'scontainer.) - At most one filled box, and only to delineate real content — a Markdown preview, a diff, a code/result block. Labels, key–value fields, question/answer text, chips: render flat on the surface, separated by spacing or a hairline divider (
height: 1px; background: ${cssVar.colorFillSecondary}), not by wrapping each in its own box. - A box on a flat surface needs a visible fill. Once the outer fill is gone, an inner
colorBgContainerbox can vanish against the tool card (same color). UsecolorFillTertiaryfor the one content box so it still reads as delineated. - Don't wrap a single value in a box just to give it padding — that's the redundant-nesting smell (a
detailCardaround avaluebox around one string).
// ❌ card-in-card: filled container wrapping a filled preview box
container: css`
padding: 12px;
background: ${cssVar.colorFillQuaternary};
`,
previewBox: css`
background: ${cssVar.colorBgContainer};
`,
// ✅ single-layer: flat container, one visible content box
container: css`
padding-block: 4px;
`,
previewBox: css`
background: ${cssVar.colorFillTertiary};
`,For the common "icon + file/title header, then one content box" shape, reuse ToolResultCard from @lobechat/shared-tool-ui/components instead of rebuilding it — it's already single-layer (flat wrapper, one colorFillTertiary content box) and is what CC Read / Grep / Glob / Write / WebSearch / WebFetch render through.
The exception is a deliberate panel pattern — an <Block variant="outlined"> with a header bar + list rows (CC TodoWrite / Task). There the single outlined block is the panel and the header fill is a header bar, not a nested card. One structured panel is fine; stacked decorative fills are not.
Streaming — Live Output During Execution (optional)
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.
Props (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).
Canonical example — RunCommandStreaming
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),
);Streaming rules
- Render
nulluntil you have something to display (avoids flash). - For terminal-style output, use
Highlighterwithanimatedto show typing-like effect. - The Streaming component must unmount cleanly when execution ends — typically the framework swaps it out for the Render automatically.
Streaming registry — client/Streaming/index.ts
import { LocalSystemApiName } from '../..';
import { RunCommandStreaming } from './RunCommand';
import { WriteFileStreaming } from './WriteFile';
export const LocalSystemStreamings = {
[LocalSystemApiName.runCommand]: RunCommandStreaming,
[LocalSystemApiName.writeLocalFile]: WriteFileStreaming,
};