@msflib/react-ai¶
Multi-Tenant LLM + Agent module built on TanStack Query, following the same approach as other @msflib/react-* modules. Wraps the be-ifusion /llm/* and /agent/* endpoints.
Install¶
pnpm add @msflib/core @msflib/react-ai
Peer dependencies¶
- react >= 18
- @tanstack/react-query >= 5
Wrap your app with AiProvider inside your QueryClientProvider.
API Reference¶
AiProvider¶
A single React context provider that fetches LLM and Agent status on mount and exposes both surfaces through two hooks — useLlm() and useAgent().
import { AiProvider } from '@msflib/react-ai';
<QueryClientProvider client={queryClient}>
<AiProvider>{/* your app */}</AiProvider>
</QueryClientProvider>;
Props
| Prop | Type | Required | Description |
|---|---|---|---|
children |
React.ReactNode |
Yes | Child component tree. |
options |
object |
No | Configuration options for the provider. See below for details. |
options Configuration¶
| Key | Type | Default | Description |
|---|---|---|---|
isWorkspaceScoped |
boolean |
true |
If true, LLM/Agent calls are isolated per workspace. |
requireAuth |
boolean |
false |
If true, status will only be fetched when isAuthenticated is true. |
isAuthenticated |
boolean |
false |
The authentication status of the user (e.g. from useAuth().status). |
protocol |
AskProtocol |
native (server default) |
Default protocol applied to every ask/search/askStream call made through useLlm()/useAgent(). Set this once for the whole app instead of passing protocol at every call site. |
channel |
string |
none | Default x-ai-channel applied the same way. |
protocol and channel are app-wide decisions in practice — one app talks to the backend via one protocol, not a different one per call — so they're configured once here, the same way isWorkspaceScoped is. A call can still override either by passing its own requestOptions (e.g. ask(payload, { protocol: 'openai' })), which wins over the Provider default for that call only; anything it doesn't specify (e.g. just { protocol: 'openai' } without channel) still falls back to the Provider's default for the rest.
In Next.js App Router, AiProvider must be in a client component (
"use client").
'use client';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AiProvider } from '@msflib/react-ai';
const qc = new QueryClient();
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={qc}>
<AiProvider>{children}</AiProvider>
</QueryClientProvider>
);
}
Multi-Tenancy¶
This module supports multi-tenancy using the isWorkspaceScoped option and the useActiveWorkspace hook from @msflib/react-shared. All API calls are automatically scoped to the current workspace if isWorkspaceScoped is true (default).
Configuration¶
Configure the AI endpoints using the configureApplication function. Both llm and agent are sub-paths under a single ai module key:
import { configureApplication } from '@msflib/core';
configureApplication({
baseURL: process.env.NEXT_PUBLIC_API_URL!, // should already include /api/v1
accessTokenKey: 'access_token',
endpoints: {
ai: {
llm: '/llm', // default
agent: '/agent', // default
},
},
});
Hook Usage¶
import { useLlm, useAgent } from '@msflib/react-ai';
function AskAssistant() {
const { status: llmStatus, ask: askLlm, loading: llmLoading } = useLlm();
const { status: agentStatus, ask: askAgent, loading: agentLoading } = useAgent();
const handleAskLlm = async () => {
const { answer, hits } = await askLlm({ question: 'How do I reset my password?' });
console.log(answer, hits);
};
const handleAskAgent = async () => {
const result = await askAgent(
{ question: 'Draft a reply to this ticket' },
{ protocol: 'mcp', channel: 'slack' },
);
console.log(result);
};
return (
<div>
<p>LLM model: {llmStatus?.llm_model}</p>
<p>Agent mode: {agentStatus?.mode}</p>
<button onClick={handleAskLlm} disabled={llmLoading.ask}>Ask LLM</button>
<button onClick={handleAskAgent} disabled={agentLoading.ask}>Ask Agent</button>
</div>
);
}
API & Hook Methods¶
useLlm()¶
Reads from the /llm/* surface.
status—GET /llm/status, fetched on mount (module,workspace_id,llm_provider,llm_model,vector_store_backend).protocols—GET /llm/protocols, fetched on mount. A map of protocol name to supported channels.search(payload, requestOptions?, mutateOptions?)—POST /llm/search. Raw vector search; returns{ hits, count }. (Noprotocolparam on this endpoint — always this shape.)ask<TResponse>(payload, requestOptions?, mutateOptions?)—POST /llm/ask. Generic over the response type, defaulting toLlmAskResponse(thenativeshape). Pass a matchingprotocoland type argument together to type the response for a different protocol — see "Protocol-typed responses" below.refetch()— re-fetchesstatusandprotocols.loading—{ status, protocols, search, ask }booleans.
useAgent()¶
Reads from the /agent/* surface.
status—GET /agent/status, fetched on mount (module,mode,workspace_id,agent_source,llm_provider,llm_model).ask<TResponse>(payload, requestOptions?, mutateOptions?)—POST /agent/ask. Generic over the response type, defaulting toAgentAskResponse(thenativeshape). Pass a matchingprotocoland type argument together to type the response for a different protocol — see "Protocol-typed responses" below.askStream(payload, requestOptions?)—POST /agent/ask/stream. The response body is a stream with no documented JSON schema, so this resolves to the rawResponse— read it yourself, e.g.const reader = (await askStream(payload)).body?.getReader().refetch()— re-fetchesstatus.loading—{ status, ask, askStream }booleans.
Protocol-typed responses¶
The backend's /llm/ask, /agent/ask and /agent/ask/stream endpoints all accept a protocol option (native, openai, langchain, ag_ui, a2ui, mcp, acp, a2a), and each protocol returns a differently-shaped response. react-ai is a transport layer, not a chat UI or a specific-protocol client — it doesn't decide which protocol you use, and it can't know the response shape of a protocol it doesn't implement itself. That decision, and the typing that goes with it, belongs to whichever app is consuming this module.
Concretely, react-ai only implements and owns one shape: native (the server's own default, { answer, hits } — exported as NativeAskResponse, aliased as LlmAskResponse and AgentAskResponse). ask() defaults to that shape and needs no extra setup:
import { useLlm } from '@msflib/react-ai';
const { ask } = useLlm();
const { answer, hits } = await ask({ question: 'How do I reset my password?' });
For every other protocol, ask() is generic — pass your own response type as a type argument alongside the matching protocol:
import { useAgent } from '@msflib/react-ai';
import type { SomeAgUiType } from '@ag-ui/client';
const { ask } = useAgent();
const result = await ask<SomeAgUiType>(
{ question: 'Draft a reply' },
{ protocol: 'ag_ui' },
);
The { protocol: 'ag_ui' } above is shown as a per-call override for illustration — in practice, if your whole app speaks ag_ui, set it once via <AiProvider options={{ protocol: 'ag_ui' }}> (see "options Configuration" above) instead of passing it on every call.
Where does SomeAgUiType come from? The protocol's own SDK — not react-ai. If your app is speaking ag_ui, openai, mcp, etc., you're already installing that protocol's client library to actually talk it (e.g. @ag-ui/client to run an AG-UI agent, the openai package to use an OpenAI-compatible client). That same library ships its own TypeScript types for its own response shapes — import from there directly. react-ai deliberately does not depend on any of these SDKs (not even as an optional peer dependency): doing so would force every consuming app to carry the weight/version churn of every protocol's SDK, even the ones they never use. Only the app that picks a given protocol pays for it.
As a starting point, here's where each protocol's types are likely to live — verify against whichever version you install, since none of these are pinned or vendored by react-ai:
| Protocol | Likely type source | Confidence |
|---|---|---|
native |
react-ai itself (NativeAskResponse) |
Confirmed — this is the shape we implement. |
openai |
the openai npm package (OpenAI Chat Completions types) |
Reasonably confident, unverified against this specific backend. |
langchain |
@langchain/core (message/response types) |
Reasonably confident, unverified against this specific backend. |
ag_ui |
@ag-ui/client / @ag-ui/core |
Reasonably confident, unverified against this specific backend. |
mcp |
@modelcontextprotocol/sdk |
Reasonably confident, unverified against this specific backend. |
a2a |
Google's A2A protocol SDK | Package name not verified — check the A2A protocol's official docs. |
acp, a2ui |
Unknown | Not verified at all — confirm with the backend team which library (if any) defines these before relying on a type. |
This module also intentionally does not know about any particular chat UI (e.g. ChatBox from @msflib/react-components). Mapping a specific protocol's response into a specific UI's props is glue code that belongs in the consuming app (or a dedicated adapter package), not here — the same reasoning as above, applied to UI instead of protocol SDKs.
payload shape (shared by ask/search)¶
{
question: string; // `query` for search
sub_thread_id?: string | null;
conversation_id?: string | null;
k?: number; // 1-50, default 4
document_type?: string | null;
source_id?: string | null;
}
requestOptions (second argument to ask/search)¶
{
protocol?: 'native' | 'openai' | 'langchain' | 'ag_ui' | 'a2ui' | 'mcp' | 'acp' | 'a2a'; // sent as the `protocol` query param
channel?: string; // sent as the `x-ai-channel` header
}
Returned Hook Shape¶
// useLlm()
{
status: LlmStatus | null;
protocols: Record<string, string[]> | null;
search: (payload, requestOptions?, mutateOptions?) => Promise<{ hits: AiDocument[]; count: number }>;
ask: <TResponse = LlmAskResponse>(payload, requestOptions?, mutateOptions?) => Promise<TResponse>;
refetch: () => Promise<void>;
loading: { status: boolean; protocols: boolean; search: boolean; ask: boolean };
}
// useAgent()
{
status: AgentStatus | null;
ask: <TResponse = AgentAskResponse>(payload, requestOptions?, mutateOptions?) => Promise<TResponse>;
askStream: (payload, requestOptions?) => Promise<Response>;
refetch: () => Promise<void>;
loading: { status: boolean; ask: boolean; askStream: boolean };
}
Notes¶
AiProvidermust be rendered in a client component in Next.js ("use client").status/protocolsqueries are cached for 60 seconds by default.- All API calls are workspace-aware if
isWorkspaceScopedis true (default). useLlm()anduseAgent()both read off the sameAiProvider— you only need to wrap your app once.