English | 简体中文
Runtime SDK for building ChatableX AI App WebUI extensions.
chatablex-web-sdk is the official JavaScript/TypeScript library that connects your web application to the ChatableX desktop client (Flutter WebView host). Unlike a type-only package, it ships the real bridge runtime — request/response RPC, event subscriptions, and tool execution callbacks.
Your WebUI runs inside a WebView. Many capabilities — native dialogs, file picking, session-aware storage, AI calls through the host pipeline — are awkward or inconsistent with browser-only APIs. This SDK exposes them as typed, promise-based modules.
- Requirements
- Installation
- Quick Start
- Project Setup
- Architecture
- Core Concept: Tool Execution
- API Reference
- Events Reference
- Permissions
- Host Capability Matrix
- Local Development
- Framework Integration
- TypeScript Types
- Best Practices
- Troubleshooting
- Examples
- Versioning
- License
| Requirement | Details |
|---|---|
| ChatableX client | Desktop app with WebView bridge (Flutter host) |
| Extension mode | execution_mode: "webapp" in manifest.json |
| Node.js | ≥ 16 (for building your WebUI) |
| Build output | webui.entry must point to ./dist/index.html (Vite or equivalent) |
| SDK install | You must npm install chatablex-web-sdk — the host does not inject the SDK |
The platform consumes two things from your extension:
- Built artifacts at
chatablex.webapp.webui.entry(typically./dist/index.html) - Bridge calls via this SDK (
ChatableX.init,sdk.tool.onExecute, etc.)
npm install chatablex-web-sdkLocal development against a monorepo copy:
npm install ../chatablex-web-sdk
# or
npm install file:../chatablex-web-sdkPackage exports (ESM + CJS + TypeScript declarations):
import { ChatableX } from 'chatablex-web-sdk';
import type { ChatableXSDK, ToolResult, ChatResponse } from 'chatablex-web-sdk';Minimal integration — handle LLM tool calls in your WebUI:
import { ChatableX } from 'chatablex-web-sdk';
async function main() {
const sdk = await ChatableX.init({
appId: 'my-counter-app', // must match manifest.json "id"
debug: true,
});
sdk.tool.onExecute(async (params) => {
const { action, value } = params;
if (action === 'increment') {
const next = (Number(value) || 0) + 1;
return { success: true, data: { value: next } };
}
return { success: false, error: `Unknown action: ${action}` };
});
}
main().catch(console.error);You do not need every module. The smallest production integration is usually sdk.tool only. Add sdk.storage, sdk.events, sdk.ui, etc. when your product needs them.
{
"id": "my-counter-app",
"name": "Counter App",
"version": "1.0.0",
"type": "app",
"execution_mode": "webapp",
"return_direct": true,
"permissions": ["notification"],
"tools": [
{
"name": "counter_control",
"description": "Control the counter widget",
"inputSchema": {
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["increment", "decrement", "get"] },
"value": { "type": "number" }
},
"required": ["action"]
}
}
],
"chatablex": {
"webapp": {
"webui": {
"entry": "./dist/index.html"
}
}
}
}| Field | Rule |
|---|---|
id |
Must equal ChatableX.init({ appId }) |
execution_mode |
Must be "webapp" |
webui.entry |
Relative path → local HTTP serve; https:// → remote URL |
tools[] |
Declares LLM-callable functions; host forwards args to sdk.tool.onExecute |
permissions |
Gates host-side APIs — see Permissions |
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"chatablex-web-sdk": "^1.0.0"
}
}Run npm run build before publishing. The ChatableX client loads dist/index.html, not your dev server (unless you configure a remote webui.entry URL).
my-app/
├── manifest.json # extension metadata
├── package.json
├── index.html # Vite entry HTML
├── src/
│ ├── main.ts # ChatableX.init() + app bootstrap
│ ├── app.ts # UI logic
│ └── bridge.ts # optional: tool routing helpers
├── dist/ # build output (consumed by host)
│ └── index.html
└── vite.config.ts
┌──────────────────────────────────────────────────────────────┐
│ Your Web App (React / Vue / Svelte / Vanilla) │
│ import { ChatableX } from 'chatablex-web-sdk' │
└────────────────────────────┬─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ chatablex-web-sdk │
│ │
│ Bridge (RPC + events) │
│ JS → Host : window.ChatableXBridge.postMessage(JSON) │
│ Host → JS : window.ChatableXReceive(JSON) │
│ │
│ Modules: tool · events · ai · ui · storage · tools · │
│ tools · platform │
└────────────────────────────┬─────────────────────────────────┘
│ WebView JavaScriptChannel
▼
┌──────────────────────────────────────────────────────────────┐
│ ChatableX Flutter Client │
│ Chat UI · SSE stream · Agent graph · SQLite storage │
└──────────────────────────────────────────────────────────────┘
Request (JS → Flutter):
{
"id": "ctx_1_1718200000000",
"method": "storage.get",
"params": { "key": "filters" },
"timestamp": 1718200000000
}Response (Flutter → JS):
{
"type": "response",
"id": "ctx_1_1718200000000",
"success": true,
"data": { "projectId": "p1" }
}Event push (Flutter → JS):
{
"type": "event",
"eventType": "toolExecution",
"data": { "action": "increment", "_requestId": "texec_1_...", "_toolName": "counter_control" }
}Tool result (JS → Flutter, fire-and-forget):
{
"method": "tool.executeResult",
"params": {
"_requestId": "texec_1_...",
"success": true,
"data": { "value": 42 }
}
}
tool.executeResultdoes not use the RPCidfield. The host correlates results via_requestId. This is required because WebViewevaluateJavaScriptcannot await Promises.
- Your bundle loads in the WebView.
- You call
ChatableX.init({ appId }). - SDK installs
window.ChatableXReceive. - SDK waits for
window.ChatableXBridge(set by Flutter). - SDK sends
sdk_inithandshake → host responds with tool metadata. - SDK exposes
window.ChatableXand returns thesdkobject.
This is the primary integration path for AI Apps. When the LLM invokes your tool, the host forwards parameters into your WebUI and waits for a result.
LLM (Agent) Flutter Host Your WebUI (SDK)
│ │ │
│ frontend_tool_call │ │
│────────────────────>│ │
│ │ event: toolExecution │
│ │ { ...args, _requestId } │
│ │─────────────────────────>│
│ │ │ onExecute(params)
│ │ │ → your logic
│ │ tool.executeResult │
│ │<─────────────────────────│
│ tool-result POST │ │
│<────────────────────│ │
│ Agent continues │ │
sdk.tool.onExecute(async (params) => {
// params includes LLM arguments PLUS host metadata:
// _toolName — which manifest tool was invoked (string)
// _requestId — correlation id (string, set by host)
return {
success: true, // required
data: { /* any */ }, // optional, returned to LLM
error: 'reason', // optional, when success is false
};
});| Return field | Type | Description |
|---|---|---|
success |
boolean |
Whether the operation succeeded |
data |
unknown |
Payload for the LLM / session (any JSON-serializable value) |
error |
string |
Human-readable error when success: false |
Rules:
- Register one handler via
onExecute. Calling it again replaces the previous handler. - Handler exceptions are caught and converted to
{ success: false, error: message }. - If no handler is registered, the host receives
{ success: false, error: 'No execute handler registered' }. - Always route multi-tool apps by
params._toolName(seegame-makerreference below). - The host times out after 30 seconds if no
tool.executeResultarrives.
sdk.tool.onExecute(async (params) => {
const toolName = typeof params._toolName === 'string' ? params._toolName : '';
switch (toolName) {
case 'counter_control':
return handleCounter(params);
case 'export_data':
return handleExport(params);
default:
return { success: false, error: `Unknown tool: ${toolName}` };
}
});Initialize the SDK and connect to the Flutter host.
| Option | Type | Default | Description |
|---|---|---|---|
appId |
string |
— | Required. Must match manifest.json id. |
debug |
boolean |
false |
Log bridge activity to console. |
timeout |
number |
10000 |
Ms to wait for ChatableXBridge during handshake. |
Returns a singleton. Subsequent init() calls return the same instance (first appId wins).
Throws if ChatableXBridge is not available within timeout.
Returns the current instance. Throws if init() has not been called.
true after the first successful init().
Current SDK version (e.g. "1.0.0").
Register and inspect your extension's tool execution handler.
| Method | Signature | Description |
|---|---|---|
onExecute |
(handler) => void |
Register the LLM tool handler. Required for webapp extensions. |
getInfo |
() => ToolInfo |
Tool metadata from host handshake (id, name, version, description). |
const info = sdk.tool.getInfo();
// { id: 'my-app', name: 'My App', version: '1.0.0', description: '...' }Subscribe to host-pushed events. Each subscription also sends events.subscribe to the host so it knows to forward matching events.
| Method | Description |
|---|---|
on(eventType, callback) |
Generic subscription. Returns unsubscribe function. |
onAiResponse(callback) |
Shorthand for 'aiResponse'. |
onToolExecution(callback) |
Shorthand for 'toolExecution'. |
onUserMessage(callback) |
Shorthand for 'userMessage'. |
const unsub = sdk.events.on('streamingContent', ({ content, finished }) => {
appendToken(content);
if (finished) setLoading(false);
});
// Clean up on component unmount
unsub();Note:
unsubscribe()removes the local listener only. The host is not notified viaevents.unsubscribein the current SDK version.
Call the host's AI pipeline from your WebUI. Requires ai_chat permission in manifest.json.
| Method | Signature | Description |
|---|---|---|
chat |
(message, options?) => Promise<ChatResponse> |
Send a message through the host AI stack. |
chatStream |
(message, options?) => Promise<unknown> |
Initiate streaming chat. Tokens arrive via sdk.events.on('streamingContent'). |
getContext |
() => Promise<SessionContext> |
Fetch current session metadata and messages. |
const reply = await sdk.ai.chat('Summarize the last three messages', {
sessionId: 'optional-override',
stream: false,
});
const ctx = await sdk.ai.getContext();
console.log(ctx.messages.length, ctx.activeTools);ChatOptions: sessionId, context, tools, skills, stream.
Drive native host UI from your WebUI.
| Method | Signature | Permission | Description |
|---|---|---|---|
showNotification |
(message, type?) => Promise<void> |
notification |
Toast: info | success | warning | error. |
showConfirm |
(title, message) => Promise<boolean> |
— | Native confirm dialog. Returns true if confirmed. |
pickFile |
(options?) => Promise<string | null> |
file_access |
Open native file picker. Returns path or null if cancelled. |
openTab |
(config) => Promise<void> |
— | Request a new tab in the host shell. |
updateState |
(state) => Promise<void> |
— | Notify host to refresh UI (e.g. { refreshMessages: true }). |
const ok = await sdk.ui.showConfirm('Delete', 'This cannot be undone.');
if (!ok) return;
await sdk.ui.showNotification('Saved', 'success');
await sdk.ui.updateState({ refreshMessages: true });FilePickerOptions: type (any | image | video | audio | custom), multiple, allowedExtensions.
TabConfig: id, title, type (chat | tool | skill | custom), optional icon, data.
Host-only:
ui.saveFile(native Save As dialog) is implemented in the Flutter host but not yet wrapped by this SDK. Advanced integrations can call it via rawChatableXBridge.postMessage.
Key-value storage persisted by the host (SQLite, scoped per tool). Use instead of localStorage when you need data to survive WebView resets and align with the desktop app.
| Method | Signature | Description |
|---|---|---|
get |
<T>(key) => Promise<T | null> |
Read a value. Returns null if missing. |
set |
<T>(key, value) => Promise<void> |
Write a JSON-serializable value. |
delete |
(key) => Promise<void> |
Remove a key. |
const KEY = 'my-app:draft';
await sdk.storage.set(KEY, { title: 'Draft', nodes: [] });
const draft = await sdk.storage.get<{ title: string }>(KEY);
await sdk.storage.delete(KEY);Storage keys are namespaced per tool instance on the host side.
List and invoke other platform tools from your WebUI.
| Method | Signature | Description |
|---|---|---|
list |
() => Promise<ToolInfo[]> |
List available tools. |
execute |
(toolId, params) => Promise<ToolResult> |
Invoke a tool immediately. |
executeWithConfirm |
(toolId, params) => Promise<ToolResult> |
Invoke after host confirmation dialog. |
const tools = await sdk.tools.list();
const result = await sdk.tools.execute('fetch-doc', { url: 'https://...' });
if (!result.success) throw new Error(result.error);Skill-type extensions (
execution_mode: "skill") are activated in the chat session and injected into the system prompt — not executed via a separate SDK module. Usesdk.toolsif your WebUI needs to orchestrate other extensions.
Platform-level utilities.
| Method | Signature | Description |
|---|---|---|
openInBrowser |
(targetUrl) => Promise<void> |
Open URL in the system browser with auth handoff. |
await sdk.platform.openInBrowser('https://docs.example.com/guide');Throws if targetUrl is empty or whitespace-only.
| Event | Payload | When fired |
|---|---|---|
toolExecution |
{ toolCall, result? } or raw args + _requestId |
LLM invokes a tool; also used internally for onExecute dispatch |
aiResponse |
ChatResponse |
AI reply completed in the host session |
streamingContent |
{ content, finished? } |
Token/chunk during streaming generation |
userMessage |
{ message, timestamp } |
User sent a message in the main chat |
close |
{ toolId } |
WebUI is about to close |
Subscribe before the event fires. Use the returned unsubscribe() function in framework cleanup hooks (useEffect return, onUnmounted, etc.).
Declare in manifest.json → permissions[]. The host rejects unauthorized API calls.
| Manifest value | SDK APIs gated | Description |
|---|---|---|
ai_chat |
sdk.ai.* |
Access host AI pipeline |
file_access |
sdk.ui.pickFile |
Native file picker |
notification |
sdk.ui.showNotification |
System toasts |
network |
(host-level) | Network access for the extension |
system_command |
(host-level) | Execute system commands |
When denied, RPC calls reject with Error: Permission denied: <permission>.
SDK methods are thin RPC wrappers. Some host handlers are fully implemented; others return stubs. Plan your extension accordingly.
| SDK method | Host status | Notes |
|---|---|---|
sdk.tool.onExecute |
Production | Core path — fully supported |
sdk.storage.* |
Production | SQLite per tool |
sdk.ui.showNotification |
Production | Requires notification |
sdk.ui.showConfirm |
Production | |
sdk.ui.pickFile |
Production | Requires file_access |
sdk.ui.updateState |
Production | Delegates to host |
sdk.platform.openInBrowser |
Production | Auth handoff |
sdk.ai.chat |
Production | Requires ai_chat + delegate |
sdk.ai.getContext |
Partial | Returns minimal context |
sdk.ai.chatStream |
Partial | Returns { streaming: true }; tokens via events |
sdk.events.* |
Production | |
sdk.tools.list |
Stub | Returns [] (host stub) |
sdk.tools.execute |
Delegate | Requires host delegate |
sdk.ui.openTab |
Stub | Returns success, no action |
ui.saveFile (raw) |
Production | Host only — not yet in SDK |
Your WebUI should work in a normal browser for UI development. Detect the host and skip SDK initialization when absent.
function isInsideChatableX(): boolean {
return typeof window.ChatableXBridge === 'object' && window.ChatableXBridge !== null;
}
async function bootstrap() {
if (isInsideChatableX()) {
const sdk = await ChatableX.init({ appId: 'my-app', debug: true });
sdk.tool.onExecute(handleTool);
} else {
console.log('Running outside ChatableX — SDK inactive');
// Use mocks, local state, or manual test triggers
}
mountApp();
}Tips:
- Use
npm run dev(Vite) for fast iteration in the browser. - Use
npm run build+ load in ChatableX for integration testing. - The host serves
dist/overhttp://127.0.0.1:<port>/for local extensions. - Set
debug: trueduring development to see bridge logs.
import { useEffect, useRef } from 'react';
import { ChatableX, type ChatableXSDK } from 'chatablex-web-sdk';
export function useChatableX(appId: string) {
const sdkRef = useRef<ChatableXSDK | null>(null);
useEffect(() => {
let cancelled = false;
let unsubStream: (() => void) | undefined;
(async () => {
if (!window.ChatableXBridge) return;
const sdk = await ChatableX.init({ appId });
if (cancelled) return;
sdkRef.current = sdk;
sdk.tool.onExecute(async (params) => {
// handle tools
return { success: true };
});
unsubStream = sdk.events.on('streamingContent', (data) => {
// update state
});
})();
return () => {
cancelled = true;
unsubStream?.();
};
}, [appId]);
return sdkRef;
}import { onMounted, onUnmounted, shallowRef } from 'vue';
import { ChatableX, type ChatableXSDK } from 'chatablex-web-sdk';
export function useChatableX(appId: string) {
const sdk = shallowRef<ChatableXSDK | null>(null);
let unsub: (() => void) | undefined;
onMounted(async () => {
if (!window.ChatableXBridge) return;
sdk.value = await ChatableX.init({ appId });
sdk.value.tool.onExecute(handleTool);
unsub = sdk.value.events.onAiResponse(handleAiResponse);
});
onUnmounted(() => unsub?.());
return { sdk };
}All public types are exported:
import type {
ChatableXSDK,
ChatableXInitConfig,
ToolInfo,
ToolResult,
ToolExecuteHandler,
ChatResponse,
ChatOptions,
SessionContext,
EventType,
EventCallbackMap,
NotificationType,
FilePickerOptions,
TabConfig,
StateUpdate,
Unsubscribe,
} from 'chatablex-web-sdk';Global window augmentation (after init):
| Global | Set by | Purpose |
|---|---|---|
window.ChatableX |
SDK | Live ChatableXSDK instance |
window.ChatableXReceive |
SDK | Host → JS message receiver |
window.ChatableXBridge |
Flutter | JS → Host postMessage channel |
window.__CHATABLEX_DISPATCH__ |
SDK | Direct tool dispatch (advanced) |
- Always call
init()once at app bootstrap, before registering handlers. - Match
appIdto manifestid— mismatches cause subtle storage and routing bugs. - Route by
_toolNamewhen your extension declares multipletools[]entries. - Return structured
data— the LLM reads tool results in the session context. - Use
sdk.storagefor persistence — notlocalStorage, if you need host-aligned state. - Unsubscribe events on teardown to avoid duplicate handlers in SPA navigation.
- Guard with
isInsideChatableX()sonpm run devworks without the desktop client. - Build before publish — the host loads
dist/, not TypeScript source. - Declare permissions upfront — don't call gated APIs without manifest entries.
- Keep handlers fast — the host enforces a 30s timeout on tool execution.
| Symptom | Likely cause | Fix |
|---|---|---|
ChatableXBridge not available |
Page loaded outside ChatableX, or init ran before channel registered | Guard with isInsideChatableX(); call init() after DOM ready |
ChatableX SDK not initialised |
getInstance() before init() |
Await init() first |
| Tool call hangs 30s then fails | onExecute not registered, or no tool.executeResult sent |
Ensure init() completed and handler is set |
Permission denied |
Missing manifest permission | Add ai_chat, file_access, or notification |
sdk_init handshake failed |
Host bridge not ready (non-fatal) | SDK continues with default metadata; check debug: true logs |
Storage returns null |
First read or wrong key | Normal on first access; verify key spelling |
| Works in dev, blank in ChatableX | Forgot to build, or wrong webui.entry |
Run npm run build; verify dist/index.html exists |
Second init() ignored |
Singleton by design | Restart WebView to re-init with a different appId |
Debug checklist:
await ChatableX.init({ appId: 'my-app', debug: true });
console.log('SDK ready:', ChatableX.isReady());
console.log('Tool info:', ChatableX.getInstance().tool.getInfo());Official sample apps under examples/. Each includes unit tests + bridge integration tests + dist/ build output.
| App | Framework | Tool | Demo flow |
|---|---|---|---|
| counter-app | React | counter_control |
get → increment → get |
| todo-app | Vue 3 | todo_control |
get → add → get (uses sdk.storage) |
npm run test:examples # run all example tests
npm run build:examples # build both dist/Both tools expose a get action so the LLM reads real state before mutating — critical for reliable multi-turn demos.
| SDK version | npm tag | Notes |
|---|---|---|
1.0.0 |
latest |
Current stable |
Breaking changes to bridge method names or tool.executeResult shape will trigger a major version bump. The Flutter host in each ChatableX client release is the canonical contract owner.
MIT © ChatableX Team