@arbidocs/react
React hooks and provider for building ARBI-powered apps. Includes @arbidocs/sdk and @tanstack/react-query — one install gets you everything.
Quickstart
npm install @arbidocs/react
1. Set up the host
import { QueryClientProvider } from '@tanstack/react-query'
import { ArbiHost, createArbiQueryClient } from '@arbidocs/react'
import { Arbi } from '@arbidocs/sdk/browser'
const { queryClient } = createArbiQueryClient()
const arbi = new Arbi({ url: 'https://www.arbidocs.com' })
function App() {
return (
<QueryClientProvider client={queryClient}>
<ArbiHost config={{ arbi }}>
<Chat />
</ArbiHost>
</QueryClientProvider>
)
}
<ArbiHost config> is the one place a host hands its configuration to the hooks: arbi, and
optionally session, documents, calendar, mail, account and dictate (each with a
default). @arbidocs/blocks re-exports it with the blocks domains too; a blocks site uses that one
(see its README).
assistantName is what people call the assistant when they address it in a chat: the @<name>
in the mention menu, the draft a shared thread starts with, and the name a message is routed to
the assistant on (useAssistantName()). It defaults to "ARBI". Under another name, a typed
@ARBI still reaches the assistant (assistantLabels), and mentions stored under an earlier name
still show as chips.
2. Build a streaming chat
import { useState, useRef } from 'react'
import { useArbi, useDocuments, useAssistantQuery } from '@arbidocs/react'
function Chat() {
const arbi = useArbi()
const [workspaceId, setWorkspaceId] = useState<string>()
if (!workspaceId) {
return (
<button
onClick={async () => {
await arbi.login('[email protected]', 'password')
const ws = await arbi.workspaces.list()
await arbi.selectWorkspace(ws[0].external_id)
setWorkspaceId(ws[0].external_id)
}}
>
Connect
</button>
)
}
return <ChatWindow workspaceId={workspaceId} />
}
function ChatWindow({ workspaceId }: { workspaceId: string }) {
const { data: docs } = useDocuments(workspaceId)
const mutation = useAssistantQuery()
const [input, setInput] = useState('')
const [response, setResponse] = useState('')
const lastId = useRef<string | null>(null)
return (
<div>
<div>{response}</div>
<form
onSubmit={(e) => {
e.preventDefault()
setResponse('')
mutation.mutate({
question: input,
docIds: docs?.map((d) => d.external_id) ?? [],
previousResponseId: lastId.current,
onToken: (t) => setResponse((r) => r + t),
onStreamStart: ({ assistant_message_ext_id }) => {
lastId.current = assistant_message_ext_id
},
})
setInput('')
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit" disabled={mutation.isPending}>
Send
</button>
</form>
</div>
)
}
That's it — three hooks and you have a streaming RAG chat.
Hooks
| Hook | Returns | Auto-enabled when |
|---|---|---|
useArbi() | Arbi instance | Always |
useWorkspaces() | User's workspaces | Logged in |
useCurrentWorkspace() | The selected workspace (workspaceId, workspaceStatus, its list entry) and my role / isMember in it | Logged in |
useActiveConfigId() | The configuration new work runs with (the chosen one, else the replied-to message's, else the last) | Always |
useShellWorkspaces(options) | Same, over a list and select you supply (no provider needed) | Always |
useDocuments(workspaceId) | Workspace documents | Workspace selected |
useConversations(workspaceId) | Workspace conversations | Workspace selected |
useTags(workspaceId) | Workspace tags | Workspace selected |
useTags(workspaceId, { kind, parentId, ids, include: ['links'], linksLimit }) | Tags filtered, with each one's documents, facts and threads (a table's rows) | Workspace selected |
useTagKindSchema(kind) / useUpdateTagKindSchema() | A kind's fields in the workspace (a table's columns); set them | Workspace selected |
useSetTagValues() | Merge-patch a tag's values for its kind's fields | Call .mutate() |
useFactsSearch({ workspaceId }) | The facts Smart Search: the query box, the hits of the last search (hybrid), search() and clear(), kept for the session under FACTS_SEARCH_VIEW | Workspace selected |
useAssistantQuery() | Streaming mutation | Call .mutate() |
useHealth() | Backend status | Pass { enabled: true } |
useProfile() / useUpdateProfile() | The user's name, picture and email; save name or picture | Logged in |
useRequestEmailChange() / useConfirmEmailChange() | Email change by verification code | Call .mutate() |
usePasswordChange() | Change the master password (one request: the password only wraps the account key) | Call .mutate() |
useNotificationPreferences(keys) | Email notification switches, optimistic save | Logged in |
useSessions() / useTerminateSession() / useLogoutAllSessions() | Active sessions, end one, sign out everywhere | Logged in |
useDeleteAccount() | Close the account (soft delete) | Call .mutate() |
useModelConnections() / useDirectModelUsage() | The main agent's model connections (box and personal) and the direct calls of the last 30 days | Logged in |
useSaveModelConnection() / useRemoveModelConnection() / useUnlockModelConnections() | Save a personal connection (its key sealed to the identity, then unlocked on this session), remove one, unlock the personal keys | Call .mutate() |
useFeature(name) / useFeatureSwitch(name) | Whether a feature is on for the user (<ArbiHost config={{ features }}>; unlisted: on), and how to switch it | Always |
The account hooks have SDK-free controllers for hosts without an arbi (useNameEditor,
useEmailChange, usePasswordChangeForm, useDeleteAccountFlow, useAvatarCrop,
toNotificationPreferences): they own the form state and take the save as a function. The blocks
/connect entry composes the lot into a settings page (see the Settings page recipe in
docs/design/blocks-package.md).
Features. A host lists the parts of the product the user can switch, and where each switch is
kept: a boolean field of the user's settings (follows the user) or this device
(createLocalFeatureStore({ key, migrate }), read synchronously from localStorage; migrate
brings an earlier build's values in once). useFeature(name) reads one anywhere; a feature the host
does not list is on, so a site that lists none offers everything.
const device = createLocalFeatureStore({ key: 'my-site-features' })
<ArbiHost config={{ features: {
skills: { userSetting: 'show_skills', default: false },
calls: { local: device, default: false },
} }}>
Selected workspace. The session's workspaceId and workspaceStatus live in a workspace
store; useSwitchWorkspace moves them and useOpenSelectedWorkspace re-opens the selection after
a reload. Give the session one kept per tab so a reload stays in the workspace
(createArbiSessionStore({}, { workspace: createArbiWorkspaceStore({ storage: browserWorkspaceStorage() }) }));
reset() it on sign-in so the next person starts on the workspace list. readWorkspaceLastOpened()
orders a "recent workspaces" list.
Thread tabs. The open conversations are the layout's tabs.threads (in the order opened, at
most MAX_THREAD_TABS), with tabs.activeThread the one on screen (threadTabId: its conversation,
or new). Each tab keeps the composer it was left with (ArbiThreadTabView: its line, edit, model
and scroll position; its draft is the chat's, per conversation), so selectThreadTab brings it back
and openThreadTab opens a thread at its latest message. They are kept across reloads with the
layout's persist, never with what was typed.
Chat. The open conversation and its composer, the turns running in each conversation, the
context documents and facts, and the agent bar's queue and run control live in a chat store
(createArbiChatStore({ layout, persist, draftStorage })), which moves with the layout's thread tabs
and tells readers once per tab change. With a persist (createStoragePersist({ parse: parseSavedChat })) the open conversation and its line, the context, each agent chat's agent and the
chosen model survive a reload; the drafts are kept apart, sealed to the person
(createSealedDraftStorage), and only for open tabs. createArbiSessionStore uses one in memory. A
host with state of its own composes its session from parts instead (composeArbiSessionStore({ layout, workspace, chat, documents })).
Documents. The document being viewed and the viewer's own state (its tab, a comparison, a tab
asked for, the text size, a Word comment to open, unsaved edits, a popped-out window), the documents
selected and those to select once listed, the document pinned to the chat, the drafts started on the
client (startDraftDocument(kind), updateDraftDocument, setDraftDocumentKind,
clearDraftDocument), the citation picking passages in the viewer (setPassageCitation) and the
related documents live in a documents store (createArbiDocumentSessionStore({ persist, showViewer })). showViewer is how the host shows its viewer for a document the session just got (a new
draft, showNewDocument for a chat's document). With a persist (load is synchronous, read from
localStorage for example, and checked with parseSavedDocuments), the viewed document and its
page, the pinned document, the viewer's tab and the text size come back on a reload before anything
renders.
Conversation settings. useConversationSettings() is the open conversation's title and sharing
(saveTitle, share, unshare: one PATCH of the conversation, which the server resolves from the
conversation's workspace) and the model of the config its next turn runs on (resetToDefaults drops
the picked one).
Artifact editing (@arbidocs/react/artifacts)
useArtifactSession({ workspaceId, docId }) loads an artifact, holds the edit buffer, saves it as a
NEW immutable version (a child document via parent_ext_id), lists the version lineage and compares
any two versions with track changes. It returns props for the @arbidocs/blocks/artifacts parts:
view (for ArtifactEditor), editorProps and redlineProps. The pieces underneath are
SDK-free and usable on their own: useArtifactEditSession (buffer, baseline, dirty, save,
compare), useRedline (diff with a pluggable, worker-capable RedlineEngine), buildVersionChain
and versionLabel. useArtifactText, useArtifactVersions and useSaveArtifactVersion are the
SDK bindings.
useArtifactText(docId) returns clean markdown and a status (loading, parsing, ready,
error): a fresh upload is parsed for a few seconds, during which the backend answers 404, 422 or
500, so it retries with a growing delay (bounded) and unescapes the backend's escaped markdown
(unescapeMarkdown; only for artifacts stored from markdown). useStreamedArtifact({ artifacts, workspaceId, settled }) covers the stream, persist, edit life of an agent draft (phase:
streaming, persisting, parsing, ready, error), cleaning the draft with
cleanDraftMarkdown (unwrapCitationLinks, stripAgentPreamble, promoteBoldTitle) before it is
stored.
PDF document tools (@arbidocs/react/document-tools)
The data side of annotation, markup, freeze, e-signing, signature checking and production sets:
DocumentToolsProvider (the host's API client, user and callbacks; deploymentDomain defaults to
the host arbi's, useDeploymentDomain()), fetchDocumentPdf (a version's /view
PDF), useDocumentMarkup, createMarkupStore (an AnnotationStore over the server markup, with
409 merge), fetchMarkupPdf, useDocumentSignatures, useSignDocument,
useSignatureVerification and buildProduction / saveProductionFiles. The UI is @arbidocs/blocks/document-tools; see
"Using the document tools in another frontend" in the @arbidocs/blocks README.
Streaming callbacks
Pass these to mutation.mutate() alongside question and docIds:
| Callback | Description |
|---|---|
onToken(token) | Each text chunk as it streams |
onStreamStart({ assistant_message_ext_id }) | Stream started — save the ID for follow-up queries |
onAgentStep(step) | Agent tool usage (searching, reading, etc.) |
onError(message) | Error during streaming |
onComplete() | Stream finished |
The query client
createArbiQueryClient() is the client every ARBI host shares (ARBI-frontend, and a blocks site
through defaultHost): reads fresh for 5 minutes and kept 30, retried twice with backoff but never
on a 4xx (except 408/429) or a read a sign-out aborted; writes never retried; a write refused while
the account's deletion is pending, or dropped by the client's settings write breaker, tells the user.
Sign-out empties it with queryClient.clear() (the same instance, so every hook keeps it).
createArbiQueryClient({ persist: true }) also returns persistOptions for
<PersistQueryClientProvider> (@tanstack/react-query-persist-client): the document metadata,
workspace list, settings, projects and entitlements are kept on this device for an offline start
(never a document's content), with writes queued offline (registerArbiMutationDefaults). Call
claimOfflineQueue(userExtId) at each sign-in, so a previous account's queued writes never replay.
ArbiWebSocketProvider keeps React Query's onlineManager in step with the browser's network, the
live connection (a dropped socket counts once it has been up) and the person's "Work offline"
choice (useWorkOffline, a transient session view, WORK_OFFLINE_VIEW). useReconnect takes the
cheapest way back: it clears "Work offline", says there is no network, or reloads.
Offline mail and calendar
Mail is kept on the device, sealed at rest to the signed-in user's key, bound to that account
and readable only by them: reading and searching recent mail offline (the last 7 days by default;
per device, Off / 1 / 7 / 30 / 90 days, useOfflineMailSettings), an Outbox whose sends go out
exactly once and in order on reconnect (each keeps its request_id), drafts written offline, and
changes made offline (read, flagged, moved, Trash, deleted: queueMailChange,
usePendingMailChanges),
applied in order on reconnect. Nothing goes out under "Work offline". The default mail client (no
host mail) already reads through offlineMailReads, and defaultHost in @arbidocs/blocks
mounts the runtime and wipes the mail at sign-out. A host with its own client and session adds
three pieces:
import { createMailClient, offlineMailReads, useOfflineMailRuntime, wipeOfflineMail } from '@arbidocs/react'
const client = createMailClient((path, init) => getArbi().mail.fetch(path, init), {
...offlineMailReads(getArbi), // reads answer from the device offline
})
useOfflineMailRuntime({ openOutbox }) // mounted once: sends the Outbox, applies changes, syncs
await wipeOfflineMail() // at sign-out
Conversations (useMailConversations, useMailThread) group by thread on the device exactly as
the server does (groupMailConversations, mailConversationKey); useNativeThreadKeys keys older
native mail into conversations in the background.
The mail kits use offlineMailSupport (@arbidocs/blocks/connect) as their device storage by
default; the per-device settings are ConnectedMailOfflineSettings.
The calendar is kept the same way (its own sealed database): reads are answered from the device offline, and event writes made offline wait in an outbox that is replayed in order once back online.
import { calendarOfflineSupport, useCalendarOfflineRuntime, wipeCalendarOffline } from '@arbidocs/react'
<ArbiHost config={{ calendar: { offline: calendarOfflineSupport } }}>…</ArbiHost>
useCalendarOfflineRuntime() // mounted once: replays the outbox on reconnect
await wipeCalendarOffline() // at sign-out
The live connection
<ArbiWebSocketProvider> holds the one reconnecting socket and applies what every site applies:
- tasks and presence;
- chats and calls;
- notification rows;
- finished runs (
response_complete): the conversation stops generating, the follow-up leaves the queue box, a background run's saved documents are read again, and a run failed for the AI usage goes to the chat host'sonQuotaExceeded; - conversation, mail-draft and skill events.
A turn's messages reach the person's other tabs from the server (conversation.message_posted).
A host handles other message types with handlers, by type, before the useArbiWebSocket
subscribers:
<ArbiWebSocketProvider enabled={signedIn} handlers={useRemoteControlHandlers()}>
Remote control. An agt-… assistant asking to drive the UI (remote_control) is consent-gated.
Its state is a session transient view (REMOTE_CONTROL_VIEW, never saved; blocks' remote control
resets it at every sign-in, sign-out, expiry and change of user): useRemoteControl(selector) reads
it, remoteControlActions(session) changes it (reset() back to the start),
and toRemoteControlRequest(message, isSequenceId) narrows the message to a runnable request. The
handler, the consent prompt and the guided cursor are @arbidocs/blocks'
(useRemoteControlHandlers, ConnectedRemoteControlConsent).
Cache invalidation
import { arbiQueryKeys } from '@arbidocs/react'
import { useQueryClient } from '@tanstack/react-query'
const queryClient = useQueryClient()
queryClient.invalidateQueries({ queryKey: arbiQueryKeys.documents(workspaceId) })
Peer dependencies
react>= 18
Tasks, agents, skills, contacts and AI usage
Hooks for an "AI workforce" and a messages screen. Writes invalidate through arbiQueryKeys, so
useTasks, useAgents, useConfigs and useChats refresh by themselves.
const create = useCreateTask() // mutateAsync(CreateTaskRequest) -> TaskResponse
const run = useRunTask() // approves a run parked for access (temporary grant), then runs
const runs = useTaskRuns(taskId) // polls while a run is in flight
const save = useSaveAgentConfig() // { agentExtId, sections: { Agents: { PERSONA, ... } } }
const grant = useGrantAgentAccess() // { agentExtId, workspaceId, mode?: { kind: 'standing' | 'temporary' } }
const contacts = useContacts() // + useInviteContacts(), useCreateContact(), useUpdateContact(), useDeleteContact()
Form-to-request mappings (taskComposerToCreateRequest, composePersona, ...) are in
@arbidocs/blocks/agents. AI usage is reported in £ (ex VAT), not dollars: use
formatGbp(n) (£0.47, £12.40, whole pounds as £10) and the metered flag on useProjectUsage
(false = this deployment does not record usage). The messenger (a
side panel, or its list and thread parts on a messages page) is ConnectedMessenger in
@arbidocs/blocks/connect. See docs/design/blocks-package.md
("Hooks and mappings a host should not rewrite").
useSessionPageTab(page, fallback) reads and writes a page's selected tab in the host session's
views.tabs, preserving it across navigation and reload. The host supplies its default tab.
Use useSessionView(key, fallback) for persisted page preferences and useTransientView for
session-only filters or one-shot requests. All three use the same session layout as the kits.