Advanced Integration Patterns
Advanced usage patterns for the Bodhi JS SDK, building on the Getting Started guide.
Login with Access Requests
The login() function accepts an options object to request access to specific MCP servers, control the authentication flow, and monitor progress.
LoginOptions Interface
interface LoginOptions {
userRole?: UserScope; // 'scope_user_user' | 'scope_user_power_user'
requested?: RequestedResources; // MCPs to request access to
flowType?: FlowType; // 'redirect' | 'popup'
redirectUrl?: string; // Custom redirect after login
onProgress?: LoginProgressCallback;
pollIntervalMs?: number; // Default: 2000
pollTimeoutMs?: number; // Default: 300000 (5 minutes)
}
type LoginProgressStage = 'requesting' | 'reviewing' | 'authenticating';
type LoginProgressCallback = (stage: LoginProgressStage) => void;
Requesting MCP Access
When your application needs specific MCP servers, request access during login. The admin reviews and approves the request:
import { useBodhi } from '@bodhiapp/bodhi-js-react';
function LoginWithAccess() {
const { login, canLogin } = useBodhi();
const handleLogin = async () => {
await login({
requested: {
mcp_servers: [
{ url: 'http://localhost:3000/mcp' },
],
},
onProgress: (stage) => {
// stage: 'requesting' -> 'reviewing' -> 'authenticating'
console.log('Login stage:', stage);
},
});
};
return <button onClick={handleLogin} disabled={!canLogin}>Login</button>;
}
Progress Stages
| Stage | Description |
|---|---|
requesting |
Submitting the access request to the server |
reviewing |
Waiting for admin approval (polls until approved/denied) |
authenticating |
Access granted, completing OAuth authentication |
MCP Agentic Patterns
Build an agentic chat loop where the LLM can discover and call tools via MCP servers. This is the full workflow from discovery through multi-turn tool execution.
Step 1: Discover MCPs and Convert Tools
List MCP servers and convert their tools to the ChatCompletionTools format expected by the chat API:
import { useBodhi } from '@bodhiapp/bodhi-js-react';
import type { ChatCompletionTools } from '@bodhiapp/bodhi-js-react/api';
const { client } = useBodhi();
// Fetch all MCPs for this app
const { mcps } = await client.mcps.list();
// Build tools array for chat completions
const tools: ChatCompletionTools[] = [];
for (const mcp of mcps) {
const mcpTools = mcp.tools_cache ?? [];
for (const tool of mcpTools) {
tools.push({
type: 'function',
function: {
name: `mcp__${mcp.slug}__${tool.name}`,
description: tool.description ?? '',
parameters: tool.input_schema as Record<string, unknown>,
},
});
}
}
Tool naming convention: mcp__<slug>__<tool-name>. The slug identifies the MCP server and the tool name identifies the specific tool within it.
Step 2: Streaming Chat with Tool Calls
Send a chat request with tools. When streaming, tool call deltas arrive incrementally and must be accumulated across chunks:
const messages: ChatCompletionRequestMessage[] = [
{ role: 'user', content: 'What is the weather in San Francisco?' },
];
const stream = client.chat.completions.create({
model: 'your-model',
messages,
stream: true,
tools,
});
// Accumulate tool calls across streaming chunks
interface AccumulatedToolCall {
index: number;
id: string;
function: { name: string; arguments: string };
}
const accumulatedToolCalls: AccumulatedToolCall[] = [];
let assistantContent = '';
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta;
// Accumulate text content
if (delta?.content) {
assistantContent += delta.content;
}
// Accumulate tool call deltas
if (delta?.tool_calls) {
for (const toolCallDelta of delta.tool_calls) {
const index = toolCallDelta.index ?? 0;
if (!accumulatedToolCalls[index]) {
// First chunk for this tool call - initialize
accumulatedToolCalls[index] = {
index,
id: toolCallDelta.id || '',
function: {
name: toolCallDelta.function?.name || '',
arguments: toolCallDelta.function?.arguments || '',
},
};
} else {
// Subsequent chunks - merge deltas
if (toolCallDelta.id) {
accumulatedToolCalls[index].id = toolCallDelta.id;
}
if (toolCallDelta.function?.name) {
accumulatedToolCalls[index].function.name = toolCallDelta.function.name;
}
if (toolCallDelta.function?.arguments) {
// Arguments are concatenated as they stream in
accumulatedToolCalls[index].function.arguments +=
toolCallDelta.function.arguments;
}
}
}
}
}
Step 3: Execute Tools and Continue the Loop
After accumulating tool calls, execute each tool and feed results back to the LLM. Loop until the model responds without tool calls:
const MAX_ITERATIONS = 10;
let loopCount = 0;
while (loopCount < MAX_ITERATIONS) {
loopCount++;
// ... (streaming or non-streaming call as shown above) ...
// If no tool calls, we are done
if (accumulatedToolCalls.length === 0) {
break;
}
// Build completed tool calls
const toolCalls = accumulatedToolCalls
.filter((tc) => tc.id && tc.function.name)
.map((tc) => ({
id: tc.id,
type: 'function' as const,
function: tc.function,
}));
// Add assistant message with tool_calls to conversation
messages.push({
role: 'assistant',
content: assistantContent || undefined,
tool_calls: toolCalls,
});
// Execute each tool and add results
for (const toolCall of toolCalls) {
const params = JSON.parse(toolCall.function.arguments);
// Parse the qualified tool name: mcp__<slug>__<tool-name>
const [, slug, toolName] = toolCall.function.name.split('__');
const mcp = mcps.find((m) => m.slug === slug);
if (!mcp) {
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify({ error: `MCP '${slug}' not found` }),
});
continue;
}
try {
const result = await client.mcps.executeTool(mcp.id, toolName, params);
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify(result ?? 'No result'),
});
} catch (err) {
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: JSON.stringify({
error: err instanceof Error ? err.message : 'Tool execution failed',
}),
});
}
}
// Reset for next iteration
accumulatedToolCalls.length = 0;
assistantContent = '';
// Continue loop - next iteration sends updated messages back to LLM
}
Non-Streaming Tool Calls
For non-streaming responses, tool calls arrive complete in a single response:
const response = await client.chat.completions.create({
model: 'your-model',
messages,
tools,
});
const message = response.choices?.[0]?.message;
const toolCalls = message?.tool_calls;
if (toolCalls && toolCalls.length > 0) {
// Process tool calls as shown in Step 3
}
Extension SDK
For Chrome extension UIs (popups, options pages, side panels), use the extension variant:
npm install @bodhiapp/bodhi-js-react-ext
import { BodhiProvider, useBodhi } from '@bodhiapp/bodhi-js-react-ext';
// Same API surface, different transport (Chrome extension messaging)
function ExtensionPopup() {
return (
<BodhiProvider authClientId="your-extension-client-id">
<PopupContent />
</BodhiProvider>
);
}
function PopupContent() {
const { isOverallReady, isAuthenticated, login, client } = useBodhi();
// Identical hook API - uses chrome.runtime messaging internally
}
The extension SDK uses chrome.runtime.sendMessage for communication instead of window.bodhiext / direct HTTP. All client.* APIs remain the same.
Custom Client Configuration
clientConfig Prop
Pass WebUIClientParams to customize the auto-created client:
<BodhiProvider
authClientId="your-client-id"
clientConfig={{
authServerUrl: 'https://id.getbodhi.app/realms/bodhi',
redirectUri: 'http://localhost:3000/callback',
logLevel: 'debug',
apiTimeoutMs: 30000,
initParams: {
extension: {
timeoutMs: 15000, // Extension discovery timeout
intervalMs: 500, // Polling interval
},
},
}}
>
<App />
</BodhiProvider>
WebUIClientParams
| Param | Type | Default | Description |
|---|---|---|---|
redirectUri |
string |
{origin}{basePath}/callback |
OAuth redirect URI |
authServerUrl |
string |
https://id.getbodhi.app/realms/bodhi |
Auth server URL |
userRole |
UserScope |
'scope_user_user' |
Default user role |
basePath |
string |
'/' |
App base path |
logLevel |
LogLevel |
'warn' |
Logging level |
apiTimeoutMs |
number |
-- | API request timeout |
initParams.extension.timeoutMs |
number |
-- | Extension discovery timeout |
initParams.extension.intervalMs |
number |
-- | Extension polling interval |
Custom Client Override (DI Pattern)
For full control, create and inject a client instance directly:
import { WebUIClient, BodhiProvider } from '@bodhiapp/bodhi-js-react';
const customClient = new WebUIClient('your-client-id', {
authServerUrl: 'https://id.getbodhi.app/realms/bodhi',
logLevel: 'debug',
});
function App() {
return (
<BodhiProvider client={customClient}>
<YourApp />
</BodhiProvider>
);
}
Using Core Packages Directly
For advanced dependency injection, use @bodhiapp/bodhi-js-react-core with @bodhiapp/bodhi-js:
import { BodhiProvider } from '@bodhiapp/bodhi-js-react-core';
import { WebUIClient } from '@bodhiapp/bodhi-js';
const client = new WebUIClient('your-client-id');
<BodhiProvider client={client}>
<App />
</BodhiProvider>
This gives you direct control over client creation while using the core React bindings (which depend only on the UIClient interface, not on any specific implementation).
Multi-Tenant
Isolate storage and routing per tenant using basePath:
<BodhiProvider authClientId="your-client-id" basePath="/tenant-a">
<TenantApp />
</BodhiProvider>
Each basePath gets its own isolated storage (connection preferences, auth tokens). The OAuth callback URL is derived as {origin}{basePath}/callback.
When connecting to a multi-tenant Bodhi server, the server may return a tenant_selection status, indicating the user needs to select a tenant. Check clientState.server.status for this:
const { clientState } = useBodhi();
if (clientState.server.status === 'tenant_selection') {
// Show tenant selection UI
}
Connection Modes
The SDK supports two connection modes: direct (HTTP to localhost) and extension (via Bodhi Browser extension).
Checking Current Mode
const { clientState, isExtension, isDirect } = useBodhi();
// clientState.mode is 'extension' | 'direct' | null
console.log('Current mode:', clientState.mode);
console.log('Is extension:', isExtension);
console.log('Is direct:', isDirect);
Switching Modes
const { client } = useBodhi();
// Switch to direct HTTP mode
await client.setConnectionMode('direct');
// Switch to extension mode
await client.setConnectionMode('extension');
Testing Connectivity
Test connectivity for either mode without switching:
const { client } = useBodhi();
// Test extension connectivity
const extState = await client.testExtensionConnectivity();
// Returns ExtensionState: { type: 'extension', extension: 'ready' | 'not-found', ... }
// Test direct connectivity (uses saved URL or provided URL)
const directState = await client.testDirectConnectivity('http://localhost:1135');
// Returns DirectState: { type: 'direct', url: '...', server: { status: '...' } }
Auto-Detection
On first initialization (when connectionMode is null), the SDK auto-detects the best mode:
- Tries direct first (lower latency)
- Falls back to extension if direct is unavailable
The selected mode is persisted and restored on subsequent page loads.
sendExtRequest (Escape Hatch)
For direct communication with the Bodhi Browser extension beyond the standard API, use sendExtRequest:
const { client } = useBodhi();
// Send a custom action to the extension
const response = await client.sendExtRequest('CUSTOM_ACTION', {
someParam: 'value',
});
This is only available when the connection mode is extension. It throws an error if the current mode is direct. Use this for extension-specific features not exposed through the standard client.* API.
Error Handling
ApiResponseResult Type
Non-streaming API methods on IConnectionClient (like sendApiRequest) return ApiResponseResult<T>:
type ApiResponseResult<T> =
| { body: T; status: number } // HTTP response (success or error)
| { error: OperationErrorResponse } // Operation error (network, extension)
Use the provided type guards to handle results:
import {
isApiResultSuccess,
isApiResultError,
isApiResultOperationError,
} from '@bodhiapp/bodhi-js-react';
const result = await client.sendApiRequest('GET', '/v1/models');
if (isApiResultOperationError(result)) {
// Network error, extension error, or other operation failure
console.error('Operation error:', result.error.message, result.error.type);
return;
}
if (isApiResultError(result)) {
// HTTP 4xx/5xx with OpenAI-format error body
console.error('API error:', result.status, result.body);
return;
}
if (isApiResultSuccess(result)) {
// HTTP 2xx success
console.log('Success:', result.body);
}
Namespaced API Error Handling
The namespaced APIs (client.chat.completions.create(), client.models.list(), client.embeddings.create(), client.mcps.*) throw errors instead of returning ApiResponseResult:
- Operation errors: Thrown as
Errorwith the operation error message - Non-streaming: Throws on operation errors; returns the response body directly on success
- Streaming errors: Thrown as
Errorwith message format"HTTP {status}: {responseText}"
try {
const response = await client.chat.completions.create({
model: 'nonexistent-model',
messages: [{ role: 'user', content: 'Hello' }],
});
} catch (err) {
if (err instanceof Error) {
console.error('Chat failed:', err.message);
}
}
For streaming:
try {
const stream = client.chat.completions.create({
model: 'your-model',
messages: [{ role: 'user', content: 'Hello' }],
stream: true,
});
for await (const chunk of stream) {
// process chunk
}
} catch (err) {
// Streaming errors thrown during iteration
console.error('Stream error:', err);
}
isOperationError
Check if an error object has the operation error structure:
import { isOperationError } from '@bodhiapp/bodhi-js-react';
// OperationError: { message: string; type: string }
if (isOperationError(someError)) {
console.error(someError.message, someError.type);
}
Common Error Types
| Error Type | Cause |
|---|---|
network_error |
Server unreachable or network failure |
extension_error |
Extension not found or communication failure |
auth_error |
Authentication/authorization failure |
timeout_error |
Request timeout exceeded |
operation_error |
General operation failure |
Further Reading
- Getting Started -- Basic setup and usage
- SDK source and additional documentation: GitHub