Frontend plugins call backend methods through a method-table-driven typed RPC: parameter and return types are checked at compile time. Backend plugins extend the table by publishing .d.ts type packages (declaration merging). Defined in web/src/plugin-api/rpc.ts.
export interface BackendRPC {
'session.get': { params: { chatID: string }; result: SessionDetail }
'session.list': { params: Record<string, never>; result: SessionSummary[] }
'agent.send': {
params: { chatID: string; content: string }
result: { turnID: number; queued: boolean }
}
'agent.cancel': { params: { chatID: string }; result: Record<string, never> }
'plugin.list': { params: Record<string, never>; result: PluginInfo[] }
'plugin.get_config': {
params: { id: string }
result: { configuration: PluginConfigSchema; values: Record<string, unknown> }
}
'plugin.set_config': {
params: { id: string; key: string; value: unknown }
result: { status: string; key: string }
}
// ---- xbot.git-fancy: fancy Git plugin data source ----
'git.status': {
params: { channel: string; chatID: string }
result: {
branch: string
repo_name: string
changes: Array<{ path: string; status: string; added: number; deleted: number }>
ahead: number
behind: number
commit_hash: string
commit_msg: string
is_repo: boolean
}
}
'git.log': {
params: { channel: string; chatID: string; limit?: number }
result: { commits: Array<{ hash: string; author: string; when: string; subject: string }> }
}
'git.diff': {
params: { channel: string; chatID: string; path: string }
result: { path: string; content: string }
}
'git.branches': {
params: { channel: string; chatID: string }
result: { current: string; branches: string[] }
}
// ---- Session usage stats (iteration_history v59 aggregate) ----
'get_session_usage_stats': {
params: { channel?: string; chat_id: string; limit?: number }
result: TenantUsageStats
}
}
TenantUsageStats (web/src/plugin-api/rpc.ts, mirrors Go sqlite.TenantUsageStats) — aggregated token / cache-hit / TTFT / TPOT stats for the current session, with per-model breakdown and recent iteration rows:
export interface TenantUsageStats {
iteration_count: number
turn_count: number
input_tokens: number // prompt tokens (persisted per-iteration since v59)
output_tokens: number
cached_tokens: number // prompt-cache hit tokens
llm_total_ms: number
avg_ttft_ms: number // NULLIF-filtered averages (zero rows excluded)
avg_tpot_ms: number
avg_tokens_per_sec: number
last_prompt_tokens: number // current context watermark (tenant_state)
current_model: string
by_model: UsageModelRow[] | null
recent_iterations: UsageIterationRow[] | null
}
export interface RPCAPI {
/** Call a backend method; name/params/result driven by BackendRPC. */
call<K extends keyof BackendRPC>(
method: K,
params: BackendRPC[K]['params'],
): Promise<BackendRPC[K]['result']>
/** One-way notification (no result awaited). */
notify<K extends keyof BackendRPC>(method: K, params: BackendRPC[K]['params']): void
}
Usage — the compiler verifies both sides:
const res = await ctx.rpc.call('agent.send', { chatID: 'x', content: 'hi' })
// res: { turnID: number; queued: boolean }
// ✗ compile error: params must be { chatID: string; content: string }
// await ctx.rpc.call('agent.send', { chatID: 42 })
FetchRpcTransport (web/src/plugin-runtime/rpc.ts) reuses the existing /api/rpc fetch channel (it does not depend on the WS connection being ready):
export class FetchRpcTransport implements RpcTransport {
async call(method: string, params: unknown): Promise<unknown> {
// Plugin methods (pluginId.method) → wrap as web_plugin_rpc; core methods pass through.
if (method.includes('.')) {
return postAPI('/api/rpc', { method: 'web_plugin_rpc', params: { method, params } })
}
return postAPI('/api/rpc', { method, params })
}
}
Two critical details:
- Envelope unwrapping —
/api/rpcreturns a{ok, data, error}envelope;postAPIunwraps it. Without this,res.pluginswould readundefined(the real data is inres.data.plugins). web_plugin_rpcwrapping — the backend RPC table has exactly ONE entry for plugin methods (web_plugin_rpc); its handler routes bypluginId.methodto the owning backend plugin process. Passing the plugin method name as the outer method yieldsunknown RPC method: xbot.git-fancy.status(no such key in the table).
PluginRpcBridge implements RPCAPI on top of the transport; notify swallows failures (fire-and-forget):
export class PluginRpcBridge implements RPCAPI {
async call<K extends keyof BackendRPC>(method: K, params: BackendRPC[K]['params']): Promise<BackendRPC[K]['result']> {
return (await this.transport.call(method as string, params)) as BackendRPC[K]['result']
}
notify<K extends keyof BackendRPC>(method: K, params: BackendRPC[K]['params']): void {
void this.transport.call(method as string, params).catch(() => { /* notify failure is silent */ })
}
}
A backend plugin that exposes RPC methods (e.g. a Git data source) publishes a type package merging into BackendRPC. Frontend plugins importing it call ctx.rpc.call('plugin.method', …) with precise types — the table above already shows the real xbot.git-fancy extension (git.status/git.log/git.diff/git.branches).
export interface SessionDetail {
chatID: string
title: string
model: string
busy: boolean
maxContext: number
maxOutput: number
tokenUsage: { prompt: number; completion: number }
createdAt: string
}
export interface PluginInfo {
id: string
name: string
version: string
enabled: boolean
}
The host constructs the bridge once in the PluginRuntime constructor:
this.rpc = new PluginRpcBridge(host.rpcTransport)
usePluginRuntimeHost supplies rpcTransport: new FetchRpcTransport(). All plugins share the same bridge instance — method routing is per-call, not per-plugin.