Skip to content

@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 }. (No protocol param on this endpoint — always this shape.)
  • ask<TResponse>(payload, requestOptions?, mutateOptions?) — POST /llm/ask. Generic over the response type, defaulting to LlmAskResponse (the native shape). Pass a matching protocol and type argument together to type the response for a different protocol — see "Protocol-typed responses" below.
  • refetch() — re-fetches status and protocols.
  • 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 to AgentAskResponse (the native shape). Pass a matching protocol and 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 raw Response — read it yourself, e.g. const reader = (await askStream(payload)).body?.getReader().
  • refetch() — re-fetches status.
  • 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

  • AiProvider must be rendered in a client component in Next.js ("use client").
  • status/protocols queries are cached for 60 seconds by default.
  • All API calls are workspace-aware if isWorkspaceScoped is true (default).
  • useLlm() and useAgent() both read off the same AiProvider — you only need to wrap your app once.