React hooks
Run a Beeuni session in React with the useSession hook.
@beeuni/sdk/react adds React hooks on top of the SDK. This page covers
useSession, the hook that runs a session end to end, and the other hooks
confirmed stable for React apps.
useSession(projectId, sessionId, options?)
useSession starts the session, opens the server-selected event transport, and
syncs messages, status, and pending prompts. Call it once per session view.
import { useSession } from '@beeuni/sdk/react';
function Chat({ projectId, sessionId }: { projectId: string; sessionId: string }) {
const s = useSession(projectId, sessionId);
if (s.phase !== 'ready') return <Booting stage={s.stage} onRetry={s.retry} />;
return (
<>
{s.messages.map(({ info, parts }) => (
<Message key={info.id} info={info} parts={parts} />
))}
<Composer busy={s.isBusy} onSend={s.send} onStop={s.cancel} />
</>
);
}
Readiness is server truth. The runtime is ready when POST /start returns
stage: 'ready'. useSession does not run a separate client-side health check.
Returns
| Field | Type | What it holds |
|---|---|---|
phase |
'starting' | 'ready' | 'error' |
Overall state. Render a boot screen until ready. |
messages |
{ info, parts }[] |
The message list. Parts stream in live. |
savedTranscript |
'loading' | 'shown' | 'none' |
Whether the saved conversation can show before the computer wakes. loading: a saved copy is on its way. shown: messages are in the list. none: nothing can show until the runtime answers. |
conversationEmpty |
boolean |
The saved copy proves the conversation empty, no turn ended since, and nothing is open or queued. Render the composer, not a boot screen. |
status |
SessionStatus |
The session status. |
isBusy |
boolean |
The agent is generating a reply. |
questions, permissions |
array | Pending agent questions and tool-approval requests. A permission here is one runtime tool approval, not an IAM permission. |
diffs, todos |
array | Live file diffs and todo items. |
sendError |
BeeuniSendError | null |
The last send failure: billing, runtime-not-ready, or runtime-error. |
rewindMessageId |
string | null |
The selected user message while a reversible rewind is staged. |
rewindPending |
boolean |
A rewind or restore request is in progress. |
rewindError |
BeeuniSendError | null |
The last rewind or restore failure. |
models, agents, defaultAgent, commands |
— | Selectable models, selectable agents, the default agent, and slash commands. Available before the runtime starts. |
retry |
() => void |
Force a re-check of /start. |
Actions
| Action | What it does |
|---|---|
send(text, override?) |
Send a prompt. override sets { model?, agent? } for this message only. |
sendParts(parts, override?) |
Send text and runtime URL file parts through the selected transport. Private staged handles use platform prompt routes. |
rewind(messageId) |
Rewind this canonical session to a user message. The selected message and later path become hidden and recoverable. |
restoreRewind() |
Restore the removed path before another prompt commits its replacement. |
cancel() |
Stop the current run and clear pending questions and permissions. Holds the session’s queued prompts first, so a queued prompt does not start a new run. |
runCommand(command, args) |
Run a project slash command. |
answerQuestion(id, answers) |
Answer a pending agent question. |
rejectQuestion(id) |
Reject a pending agent question. |
answerPermission(id, reply, message?) |
Answer a tool-approval request. reply is 'once', 'always', or 'reject'. |
useSession also returns removeQuestion and removePermission. Do not use
them. They clear the prompt from local state but never notify the agent, so
the run stays blocked. Use answerQuestion, rejectQuestion, or
answerPermission instead.
Options
| Option | Default | What it does |
|---|---|---|
waitMs |
15000 |
The long-poll budget sent to /start. |
replayStartStash |
true |
Replay a prompt saved before the session existed, once the session is ready. |
enabled |
true |
Set false to delay the hook, for example until a billing check passes. |
chatEngine |
true |
Set false if your app mounts its own chat surface for this session, to avoid syncing messages twice. |
initialRuntimeSessionId |
null |
A runtime session pin your server already holds for this session. The transcript renders from it while /start runs; the /start answer stays authoritative. initialOpenCodeSessionId is its deprecated name. |
browserPresence |
false |
Hold this tab’s presence lease while a signed-in person uses the page (input in the last 10 minutes). The lease keeps the session’s computer awake. |
presenceAlerts |
false |
This tab shows its own notifications. Sent on the presence lease: the server skips the phone and Web Push only while an alerting tab is in use. A change is sent at once. Pass true only when the project’s notification_center flag is on; the server ignores it otherwise. Needs browserPresence. |
presencePageExit |
false |
End the presence lease when the page closes or enters the back/forward cache, so a turn that ends right after the tab closes still notifies. On, pagehide reports absent, and every absent report is sent with keepalive, so it outlives the page. Off: no pagehide report and no keepalive. A closing tab still turns hidden and reports absent, but the browser can cancel that request as the page unloads; the lease then lives to its 90 s expiry. Pass the project’s notification_center flag. A change applies at once, with no new presence report. Needs browserPresence. |
repositoryMode |
— | Telemetry only. A session created before a repository replacement starts and runs the project’s current config without it. Only its /workspace clone still comes from the old repository, so a push from it needs a rebase first. |
Sending is optimistic. send shows your message right away, then stream
events fill in the agent’s reply.
rewind(messageId) never creates a session. It uses the canonical session from
POST /start. The runtime restores file state and keeps the removed transcript
path recoverable. The next accepted prompt commits the replacement path.
Other stable hooks
@beeuni/sdk/react also exports React Query hooks for data that does not
need a running session. Each mirrors a method on the
client and needs no provider.
| Hook | Reads |
|---|---|
useProjectModels(projectId) |
Selectable models for the project. |
useProjectModelPickerCatalog(projectId) |
The raw /model-picker record per wire model (reasoning_options, temperature, limit) and managedPricingRoutes customer price estimates for each eligible managed route. The serving route determines the final credit charge. |
useProjectSessions(projectId, { parent, startedBy, q, limit }) |
One project’s sessions, paged. parent: 'root' returns top-level sessions with child_count; startedBy: 'me' | 'others' | 'automated' filters by who started the run; q searches every session the viewer may open, not only loaded pages. Each filter set has its own cache entry. |
useSessionChildren(projectId, parentSessionId, { q, limit, enabled }) |
The sessions one parent spawned (parent=<id>), newest first, paged. Set enabled: false until the parent is opened. |
useVisibleAgents({ projectId }) |
The project’s visible agents. |
useProjectConfig(projectId) |
The project’s runtime config: default agent, commands. |
useProjectSecrets(projectId) |
Secrets: list, add, remove, and personal overrides. |
useAccountSecretResources(accountId) |
Account provider-secret metadata and use grants. Values are never returned. |
useSessionProviderSecretPools(projectId, sessionId) |
Configured pools, including empty selections; setPool updates one provider and refreshes the session cache. |
useProjectTriggers(projectId) |
Triggers: list, create, update, remove, fire. |
useProjectTriggerEventTypes(projectId, connector) |
The app events a connector can trigger on. Idle until connector is set. |
useChangeRequests(projectId, status?) |
Change requests: list, open, merge, close, request changes. |
useRuntimeSupports(capability) |
Whether the open session’s runtime serves a feature (session.rewind, session.compact, …), from the /beeuni/health answers the runtime-reconnect poller reads. true until the first answer. See What the runtime supports. |
useNotificationInbox({ userId, limit?, enabled? }) |
The caller’s newest inbox page (limit default 20), unreadCount, and markRead(ids), markAllRead(), markSessionRead(sessionId). Polls every 60 s while the page is visible and refetches on focus. A write changes the cache at once, restores it on failure, then refetches every inbox entry. No request without userId. |
useNotificationPreferences({ userId, enabled? }) |
The caller’s push and email choice per kind, email_available, and update(patch) (optimistic). |
useSessionWatch({ userId, projectId, sessionId, enabled? }) |
{ watching } for one session, and setWatching(watching) (optimistic) to mute or watch it. |
resetRuntimeQueries(queryClient) |
Not a hook. Drops every cached runtime query (sessions, messages, agents, commands, diffs). Call it after the session’s runtime is replaced by a restart or a config reload, so the next read comes from the new runtime. |
The three notification hooks key their cache by userId (qk.notifications),
so a user switch in the same tab never shows the previous user’s inbox. Pass
the signed-in user’s id.
Notifications are behind the per-project notification_center
feature flag, off by default. Gate each hook with
enabled: useFeatureFlag(projectId, 'notification_center').enabled, so a
project with the flag off sends no notification request.
Connector setup links
An agent can mint a 1-click connect link (/connect/<token>). The flow’s data
and runtime policy live in the SDK; a host renders its own dialog over them and
injects its adapters.
| Export | Role |
|---|---|
useConnectorSetup(token, options) |
The whole flow: load what the link names, POST /start, open the provider’s hosted page through the injected openPopup, poll POST /finalize on the 3 s / 5 s / 5-minute schedule, and report phase (loading, ready, starting, opened, connected, error), the app info, the error, and who the account was authorized as. options carries the host’s backendUrl, storage adapter and openPopup; onOpened fires after each popup opening. |
useConnectorLinkInfo(token, options) |
One shared, storage-backed cache per link token, so every card and the modal share one GET of the rate-limited public route and paint the app’s logo on the first frame. The same backendUrl/storage options. |
createConnectorLinkInfoCache(fetchInfo, { storage }) |
The cache factory itself (framework-free, storage injected) for a host that wants its own instance. |
resolveConnectorStart({ start, finalize }) |
The /start outcome rule: hosted url → popup, already connected → finalize once, otherwise the error message. |
nextConnectorPollDelay(attempt, elapsedMs) |
The bounded poll schedule (3 s first, 5 s after, stops at 5 minutes). |
Connector data
useConnectorQuery(projectId, slug, action, args, { account, enabled, staleTime })
reads one connector action’s output as a cached query. error is a
ConnectorCallError: render a connect button from error.connectUrl, or an
account picker from error.availableAccounts. Use it only for an action that
reads; every fetch runs the action and writes an audit event. The output stays
fresh for 30 s, focus never refetches, and only an upstream 429 or 503 is
retried. See Connectors from code.
Start the open read early
prefetchSessionOpen(queryClient, projectId, sessionId) starts the session-open
read before the session view mounts. It issues one GET .../snapshot request
(session row, turn, queue, and the first 40 transcript messages) and seeds the
session row cache from it. useSession then uses that request instead of
starting its own.
Call it when a route names a session, or on hover, focus, or touch of a session
link. It is read-only: it never calls /start and never wakes a computer. It
never rejects. A repeat call for the same session within 30 seconds sends no
request.
import { prefetchSessionOpen } from '@beeuni/sdk/react';
<a
href={`/projects/${projectId}/sessions/${sessionId}`}
onFocus={() => void prefetchSessionOpen(queryClient, projectId, sessionId)}
/>;
Render from cache on a reload
Two stores keep what a user already saw, so a reload or a cold app start renders it in the first frame and refreshes it in place. Both are per user and bounded, and a host clears both on sign-out.
createPersistedQueryCachekeeps the React Query results a user navigates by: accounts, projects, project detail, the paged session list, and session rows (isPersistableQueryKey). A restored entry keeps its original age, so it refetches on mount. The default bounds are 1,000,000 characters and 7 days.createSavedCopyStorekeeps the last saved copies of transcripts that the server sent. Register it withsetSavedCopyStore, anduseSessionpaints the kept copy before the first frame. The server’s copy then reconciles into it by message ID, and the SDK re-reads it 3 seconds after each turn ends. The default bounds are 12 sessions, 1,500,000 characters, and 14 days. A copy over 300,000 characters keeps its newest messages that fit; older messages load from the server when the user scrolls up.
import {
browserKeyValueStorage,
createPersistedQueryCache,
createSavedCopyStore,
isPersistableQueryKey,
setSavedCopyStore,
} from '@beeuni/sdk';
const storage = browserKeyValueStorage(); // null where there is no localStorage
if (storage) {
const cache = createPersistedQueryCache({ storage, userId, shouldPersist: isPersistableQueryKey });
cache.restore(queryClient); // synchronous with localStorage: no pending frame
const stop = cache.persist(queryClient);
setSavedCopyStore(createSavedCopyStore({ storage, userId }));
}
On React Native, pass AsyncStorage as storage. restore then returns a promise.
The saved-copy store keeps only server captures: the transcript mirror that the API writes when a turn ends. The live transcript is never written to the device, so a stopped turn cannot come back as a running one. A runtime read that returns no messages keeps the saved messages on screen: it means the computer lost its state, not that the conversation is empty. The first runtime read with messages reconciles them.
Sign-out and account switching
The SDK keeps per-user session state in memory: transcripts, pending questions
and permissions, turn receipts, and model picks. Call resetIdentityState() on
every identity change: sign-out, and a different user signing in. This includes
a sign-in in another tab that changes the user without a page load.
Call this handler when your authentication flow signs a user in or out.
import { resetIdentityState } from '@beeuni/sdk/react';
let previousUserId: string | null = null;
function onIdentityChange(userId: string | null) {
if (userId !== previousUserId) {
queryClient.clear();
resetIdentityState();
}
previousUserId = userId;
}
resetIdentityState() also removes the model store’s localStorage entry. It
does not clear the host’s own caches, such as React Query, or the token source
passed to createBeeuni. Reset those in the same handler. It never throws.
With the stores from Render from cache on a reload,
clear them in the same handler: await cache.clear(), then
await savedCopies.clear() and setSavedCopyStore(null). Neither store ever
restores data for a different user, but a signed-out device should not keep it.
Next
- Sessions — the session handle
useSessionwraps, and theBeeuniSendErrorkinds. - Reference — the full REST surface these hooks read from.
Saved history during startup
useSession(projectId, sessionId) shows saved chat history while its computer starts.
The SDK reads the database independently of runtime readiness. Live messages then reconcile
by their original IDs. Hosts do not need a separate history request or message store.
Saved history renders exactly like the live transcript: text, reasoning, and every tool call
with its input, output and error. A sub-agent’s full view reads the sub-agent’s own saved
transcript: pass the parent’s scope and savedChild: true to
useSessionSync(subAgentSessionId, { beeuniSessionScope, savedChild: true }). The initial window contains the newest 40 messages, at
most 1,000,000 characters. Scrolling up loads older windows from saved history, also while
the computer is off. Attachments that require the computer become available when the
runtime is ready.
savedTranscript tells a host what to render while the computer starts. It is loading while
a saved copy may still arrive, shown once messages are in messages, and none when nothing
can show before the runtime answers. An unknown is loading, never none.
conversationEmpty is true when there is nothing to wait for. The saved copy proves the
conversation empty: a complete read of the session’s runtime found no messages. No turn ended
since, and nothing is open or queued. Missing records never make it true, so an older session
with history is never shown as empty. A session gets that proof at its first start with saved
history on.
if (s.messages.length === 0) {
// Nothing saved and nothing to wait for: the user can type now.
if (s.conversationEmpty) return <Composer />;
// One round trip for a saved copy; the computer can take minutes.
return s.savedTranscript === 'none' ? <Booting stage={s.stage} /> : <MessageSkeleton />;
}
Tool calls saved before 2026-09-28 lost their input and output. The server serves each one with the input its kept title or metadata proves: a command’s command and output, a file call’s path, a search’s pattern, a sub-agent’s description, a fetch’s URL, a todo list, a skill’s name. The session’s next start saves the whole history again, 1.
Pending prompts
useSessionPrompts(projectId, sessionId) submits to the durable session inbox.
The server runs pending prompts automatically, each as its own turn.
Pass placement: 'transcript' to display a pending message in the conversation.
It runs before every composer entry and ends the active response after the
current tool call completes.
Pass placement: 'composer' to keep it in the editable list above the input.
It waits for the active response to finish. Each placement keeps submission order.
Pass delivery: 'steer' | 'queue' | 'interrupt' to say how the prompt reaches a
running turn. steer hands it to the turn at its next step; the turn does not
stop. queue waits for the turn to end. interrupt ends the turn after the
running tool. With only delivery, the SDK sends the placement it implies:
interrupt is transcript, the others are composer. A steer row that cannot
steer reads delivery: 'queue' with steer_fallback set to unsupported,
not_prompter or turn_ended. interrupt(promptId) (“Stop and send”) turns a
waiting row into a transcript entry; a row already on the wire answers 409.
The web composer sends Enter as steer while a turn runs, and as a transcript
send while the session is idle. Command+Enter (Control+Enter on Windows/Linux)
is composer placement. Shift+Enter inserts a newline.
List rows include full_text for complete previews after reload. text remains
the bounded preview for older clients. Attachment bytes are excluded from list
responses. Delete returns the complete prompt for lossless undo.
Pending delivery and active responses
useSessionWorking() keeps state: 'working' while a prompt waits in the durable
inbox. This keeps Stop available. pendingDelivery: true marks a prompt still on
its way to the runtime; the web app shows one working indicator whenever state
is working, whatever that flag says. Active runtime turns and fresh runtime
activity clear it. A timed-out cancel does not confirm that execution stopped.
A worker claim only checks admission and keeps the prompt waiting. Delivery starts after admission succeeds. A confirmed active turn clears the pending presentation even if the previous inbox snapshot still lists that prompt. Runtime activity preserves the active turn’s message ID during this handoff.
The web composer names Command/Ctrl+Enter Queue List and a row’s “Stop and send” Quick Queue. Quick Queue entries run first. A waiting Quick Queue entry is a tinted bubble after delivered turns, with no status text. Queue List entries remain editable until delivery starts. The server recovers missed completion events from exact runtime evidence, including after reload, without treating an unreachable runtime as a completed turn.
Each distinct Enter displays its pending prompt immediately, even while an earlier acceptance request is unresolved. Only a delivery failure adds text: its cause, Retry, and Remove. The server inbox continues to own execution order.
The hook reads queue changes from the session’s control stream
(GET .../sessions/:id/events?channels=control), one connection per session
however many components mount it. Every write of an inbox row arrives as a
beeuni.control.queue frame. The GET .../prompts poll runs only while that
stream is not connected.
Why a turn ended
The transcript of an interrupted turn only carries MessageAbortedError. The
control plane keeps what it knows about how each turn ended: the cause the
sandbox named, for example SandboxMemoryGuard, and which turns died with no
cause named. A stop somebody asked for — the Stop button, abort(), a prompt
sent into a busy session — is recorded as a request and is never reported as a
failure.
useSessionTurnOutcome(projectId, sessionId) returns last_ended,
recent_failures and the read time atMs from the cache useSessionWorking()
keeps fresh. It makes no request. Pass the result to turnEndNotice, with what
the transcript says about the turn:
const outcome = useSessionTurnOutcome(projectId, sessionId);
const notice = turnEndNotice(outcome, turn.userMessage.info.id, {
hasError: Boolean(turnError),
isAbort: turnErrorIsAbort,
});
// notice: { kind: 'sandbox-memory', usedPct: 97, detail: 'sandbox memory at 97% …' }
// | { kind: 'cause', name: 'SomeGuard', message: '…' }
// | { kind: 'unexplained' }
// | null
The host maps each kind to its own copy and never parses the sandbox’s
message. null means “show what the transcript shows”: a real error stays, an
abort renders nothing. An unexplained failure is reported only once the reading
is a few seconds past the turn’s end, because the cause often arrives one frame
after the abort. turnEndCause(outcome, messageId) returns the raw recorded
cause, or null.