Skip to content
Subako TypeScript SDK
Esc
navigateopen⌘Jpreview
On this page

React

The provider and the four hooks, and what each of them owns.

@subako-ai/react opens a session and declares, for as long as a component is mounted, the tools that let an agent drive the page the person is already on. The events are the source of truth — everything here reads the SDK’s session connection and derives the rest.

Requires React 19.

pnpm add @subako-ai/react @subako-ai/sdk

The provider

Build the client once, outside React, and hand it to the provider:

import { SubakoProvider } from "@subako-ai/react";
import { SubakoSessionClient } from "@subako-ai/sdk";

const subako = new SubakoSessionClient({
	getToken: (sessionId) => fetch(`/api/subako/token/${sessionId}`).then((r) => r.text()),
});

export function App({ sessionId }: { sessionId: string }) {
	return (
		<SubakoProvider client={subako}>
			<Assistant sessionId={sessionId} />
		</SubakoProvider>
	);
}

The provider holds nothing but the client and an onError: there is no registry of open sessions. Each hook opens what it owns in an effect and closes it in the cleanup, so it hands back null until that effect has run — the first render — and each hook takes the null the one above it may still be handing back. That is also what makes them safe under <StrictMode>, whose dev-only remount closes the connection and lets the effect that follows open another.

The four hooks

Three of them mirror the SDK’s three layers, and the common case is one of each:

const session = useSession(sessionId); // one SessionConnection, opened with the history
const client = useToolClient(session, "chat"); // one client on it, named to the model
useTool(client, "navigate", navigateTool(fromTanStackRouter(router)));

The fourth is the read. useSession acquires; useSessionState reads. Opening a connection and following it are separate things, the way valtio splits proxy from useSnapshot: useSession hands back a connection and re-renders its caller once, when there is one, and every component that draws what the session carries calls useSessionState(session).

function Transcript({ session }: { session: SessionConnection | null }) {
	const state = useSessionState(session); // without this, the props never change and this never redraws
	return (
		<>
			{state?.transcript.map((message) => (
				<Message key={message.seq} message={message} />
			))}
		</>
	);
}

useSession

function Assistant({ sessionId }: { sessionId: string }) {
	const session = useSession(sessionId);
	const state = useSessionState(session); // this component draws the session, so it subscribes
	if (session === null || state === null) {
		return <p>Connecting…</p>;
	}

	return (
		<>
			{state.transcript.map((message) => (
				<Message key={message.seq} message={message} />
			))}
			{state.approvals.map((approval) => (
				<button key={approval.call_id} onClick={() => session.resolveApproval(approval.call_id, "allow")}>
					Allow {approval.tool}
				</button>
			))}
			<Composer onSend={(text) => session.send(text)} disabled={state.isRunning} />
		</>
	);
}

It hands back the SDK’s own SessionConnection, opened with history: true and closed on unmount. Everything on it is the SDK’s; the hook adds nothing. A second argument passes the SDK’s connect options through.

Connecting is an effect, so the type is SessionConnection | null: the first render — and the renders after sessionId changed — read null.

The hook does not reconnect on its own. The SDK’s connection reconnects while it can, and after maxReconnects consecutive failures its status is failed and it stays there. What puts it back is session.reconnect(), so an app renders a button rather than expecting the hook to try again:

const state = useSessionState(session);
if (state?.status === "failed") {
	return <button onClick={() => session?.reconnect()}>Reconnect</button>;
}

The hook owns the connection, so useSession(id) in two components opens two connections. That is not a mistake — the server’s log is the source of truth, so both read the same session and behave alike — but it is work nobody asked for. Call useSession once and pass the connection down; each place subscribes for itself.

useSessionState

const state = useSessionState(session); // SessionState | null

useSyncExternalStore over the session: it re-renders the calling component whenever the connection changes and hands back the snapshot that render was woken for, or null while the session is null.

Call it in every component that renders the session, and only there:

  • The component that renders is the one that subscribes. useSession in a provider and <Chat session={session} /> in its children is the shape that catches people out: the provider re-renders, the chat’s props never change, and React skips it. Subscribing inside the chat is what fixes it.
  • A component that only passes the session along, or only calls send, cancel or resolveApproval, needs nothing.

It takes anything carrying the connection’s state and subscribe, so it works on a session from useSession, on one built with the SDK directly, and on the null either may still be.

useToolClient

const client = useToolClient(session, "chat", { lifetime, onError });

Creates one client on the session for the component’s lifetime and deletes it on unmount. It takes the null useSession may hand back and answers with ToolClient | null in turn.

name is what the model sees the tools under — every tool reaches it as client__<name>__<tool> — and the server’s rule for it is [a-z0-9-]{1,32}.

Several useToolClient calls on one session are fine: each is its own named client on the one connection, which is how a session serves tools from more than one place (chat next to contact-form). Most apps want one. A tab that goes away without unmounting sends no delete; the client’s ttl collects it instead.

lifetime and onError are read when the client is created, and a later change to either is ignored — the lifetime is the server’s, fixed at registration, and onError is the callback the client kept. Only a new session or a new name makes another client. So an onError that has to see fresh state reads it out of a ref, or is the provider’s.

useTool

function ContactPage({ client }: { client: ToolClient }) {
	const form = useForm({ defaultValues: { email: "", message: "" } });
	const formRef = useRef<HTMLFormElement>(null);

	useTool(client, "navigate", navigateTool(fromTanStackRouter(router)));
	useTool(client, "fill_contact_form", formTool({ ...fromReactHookForm(form), description: "The contact form." }), {
		target: formRef,
	});

	return <form ref={formRef}>{/* ... */}</form>;
}

The tool is offered for as long as the component is mounted: the mounted components are the set, and the client reconciles it with the server, one update per tick, so a page that swaps a screenful of tools sends one request.

A re-render that builds a fresh definition swaps the handler and tells the server nothing, unless the description or the parameters changed. So there is nothing to memoize: build the definition inline on every render and its execute closes over what the component holds right now.

A client with no tools stays registered: it belongs to the session, not to the tools. A null client declares nothing, so the call reads the same whether the session is open or not.

Was this page helpful?