React API
@flow-state-dev/react — React hooks, renderers, and context providers.
Peer dependency: react ^18.0.0 || ^19.0.0
FlowProvider
import { FlowProvider } from "@flow-state-dev/react";
<FlowProvider
flowKind="my-app"
sessionId="optional-initial-session"
userId="devuser"
baseUrl="/api/flows"
renderers={{
message: MessageComponent,
reasoning: ReasoningComponent,
component: {
"chart": ChartComponent,
},
}}
>
{children}
</FlowProvider>
Nested providers merge renderers (child keys override parent keys).
Hooks
useFlow(options?)
Session lifecycle management.
const flow = useFlow({ autoCreateSession: true });
flow.sessions; // SessionDetail[]
flow.activeSessionId; // string | null
flow.createSession(); // Promise<string>
flow.selectSession(id); // void
useSession(sessionId, options?)
Primary hook for session data and actions.
const session = useSession(sessionId, {
items: true, // default
items: false, // skip items
items: { visibility: "ui" }, // filter by visibility
items: { includeTransient: false }, // exclude transient items
});
session.detail; // SessionDetail | null
session.snapshot; // SessionStateSnapshotResponse | null
session.latestRequest; // SessionRequestSummary | null — most recent request (any status)
session.items; // OutputItem[] — includes sub-agent items
session.messages; // MessageItem[]
session.blockOutputs; // BlockTraceItem[]
session.functionCalls; // FunctionCallItem[]
session.isLoading; // boolean
session.isStreaming; // boolean
session.error; // Error | null
// Identity-based filtering:
session.getItemsByAgent("researcher"); // items stamped with agentName
session.getItemsByVisibility({ history: false }); // items by visibility
// Container-scoped items:
session.getOwnedItems(containerBlockInstanceId);
await session.sendAction("chat", { message: "Hello!" });
await session.abortRequest(); // signal in-flight request to stop
await session.resumeLatestRequest(); // re-dispatch latest if interrupted/failed
await session.resumeSuspension({ // approve/reject a suspension, stream the continuation
suspensionId: "susp_1",
requestId: "req_1",
action: "approve",
});
session.refresh();
resumeLatestRequest is a no-op unless latestRequest.status is interrupted or failed. The server creates a new request that re-runs the original action with the same input, and the hook auto-attaches to its stream.
resumeSuspension resolves a pending durable-execution suspension and streams the resumed continuation back into session.items, so the resolution renders live (no refresh) even on serverless. requestId is the suspended request's id (carried on the suspension item); the continuation re-enters that same id. This is the streaming resume useSuspensions and <SuspensionResolverProvider> build on.
useClientData(session, options)
Read client data values from session state snapshot.
// String array mode — subscribe by name
const data = useClientData(session, {
session: ["activePlan", "messageCount"],
user: ["preferences"],
});
// Schema mode — subscribe with type inference
const data = useClientData(session, {
session: {
activePlan: activePlanSchema,
},
});
useAction(options)
Low-level action execution.
const { execute, loading, error } = useAction({
flowKind: "my-app",
action: "chat",
userId: "devuser",
});
await execute({ message: "Hello!" });
useRequestStream(options)
Direct request-stream access. Message and reasoning text streams in token-by-token.
const { items, status, isStreaming } = useRequestStream({
source: { requestId },
filter: { itemTypes: ["message", "component"] },
});
Use source: { response } to consume a pre-fetched POST stream (inline streaming) instead of opening a separate GET-by-id connection.
useVoice(session, options)
Voice input/output composing with useSession.
import { useVoice } from "@flow-state-dev/react";
const voice = useVoice(session, {
action: "run",
buildInput: (transcript) => ({ message: transcript }),
});
voice.isListening; // boolean — mic is recording
voice.isSpeaking; // boolean — audio playback active
voice.isProcessing; // boolean — server transcribing
voice.interimTranscript; // string — browser speech recognition (interim)
voice.startListening(); // start recording
voice.stopListening(); // stop recording, transcribe, send action
voice.stopSpeaking(); // stop audio playback
See the Voice guide for full usage details.
Renderers
ItemRenderer
Render a single item using the registered renderer.
import { ItemRenderer } from "@flow-state-dev/react";
<ItemRenderer item={item} />
ItemsRenderer
Render a list of items. By default, conversational items with history: false visibility (sub-agent output) are filtered out — they're available in session.items but hidden from the default conversation view so orchestrator chatter doesn't crowd the UI. Pass showSubAgents to surface them inline, or render a per-agent view via session.getItemsByAgent(name).
import { ItemsRenderer } from "@flow-state-dev/react";
// Default — filters sub-agent items
<ItemsRenderer items={session.items} />
// Opt in — show sub-agent items inline
<ItemsRenderer items={session.items} showSubAgents />
Custom Renderers
import type { MessageItem } from "@flow-state-dev/core/items";
function ChatMessage({ item }: { item: MessageItem }) {
return <p>{item.role}: {item.content[0]?.text}</p>;
}
// Register in FlowProvider
<FlowProvider renderers={{ message: ChatMessage }}>
// Suppress a type
<FlowProvider renderers={{ status: false }}>
RendererRegistry
Type for the renderers map:
type RendererRegistry = {
message?: ComponentType<{ item: MessageItem }> | false;
reasoning?: ComponentType<{ item: ReasoningItem }> | false;
suspension?: ComponentType<{ item: SuspensionItem }> | false;
component?: Record<string, ComponentType<{ item: ComponentItem }>>;
container?: Record<string, ComponentType<{ item: ContainerItem }>>;
// ... other item types
};
Pass false for any slot to suppress its built-in fallback renderer.
SuspensionResolverProvider
Bridges a session's streaming resume to the inline default <ApprovalRenderer>. Wrap the subtree that renders session.items and pass session.resumeSuspension; the inline card then streams the continuation into the chat view instead of doing a non-streaming resume. Without it, the card still resolves — just without live output until the session refetches.
import { SuspensionResolverProvider } from "@flow-state-dev/react";
<SuspensionResolverProvider resolve={session.resumeSuspension}>
<ItemsRenderer items={session.items} />
</SuspensionResolverProvider>
Suspensions
useSuspensions(session, options?)
Derives pending and resolved suspensions from session.items. Pairs each suspension item with its suspension_resume item by suspensionId. approve/reject stream the resumed continuation back into session.items (via session.resumeSuspension), so the resolution renders live.
const {
suspensions, // SuspensionView[] — all suspensions matching options
pending, // SuspensionView[] — subset where pending === true
resolve, // (id: string, { action, data }) => Promise<void> — general resolver
approve, // (suspensionId: string, data?: unknown) => Promise<void>
reject, // (suspensionId: string, data?: unknown) => Promise<void>
error, // Error | null — most recent failed resolve call
} = useSuspensions(session, {
requestId: "req_abc", // optional: restrict to one request
reasons: ["human_approval"], // optional: restrict by reason
});
resolve(id, { action, data }) is the general resolver: action is "approve" | "reject" | "submit" | "skip", and submit carries the typed payload in data. approve(id, data) and reject(id, data) are thin wrappers over it.
Each SuspensionView has:
interface SuspensionView {
item: SuspensionItem;
status: SuspensionStatus; // "pending" | "approved" | "rejected" | "submitted" | "skipped" | "timed_out" | "expired"
pending: boolean;
resumeData?: unknown;
resolvedBy?: string;
allow: ResumeAction[]; // permitted actions, e.g. which controls to show
isResolving: boolean; // true while a resolve is in flight for this suspension
}
resolve, approve, and reject rethrow on failure so callers can branch on the error. The last failure is also captured in error.
useSuspensionForm(item, options?)
Headless controller for the non-binary input shapes — a clarifying question, a flat form, or a single/multi selection. Where useApproval drives the binary gate, this drives the submit / skip path. It derives form fields from the suspension's resumeSchema (bounded to a flat object of scalars and enums, or a single top-level scalar/enum), holds the in-progress value, validates it client-side, coerces numbers, and resolves through the same streaming transport.
import { useSuspensionForm } from "@flow-state-dev/react";
function ClarifyCard({ item }) {
const f = useSuspensionForm(item);
if (f.resolved) return <span>{f.outcome.icon} {f.outcome.label}</span>;
return (
<div>
{f.fields.map((field) => (
<label key={field.key}>
{field.label}
<input
value={String(f.value[field.key] ?? "")}
onChange={(e) => f.setField(field.key, e.target.value)}
/>
{f.errors[field.key] && <em>{f.errors[field.key]}</em>}
</label>
))}
<button disabled={!f.canSubmit} onClick={f.submit}>Submit</button>
{f.canSkip && <button onClick={f.skip}>Skip</button>}
</div>
);
}
Return shape:
interface UseSuspensionFormResult {
kind: SuspensionValueKind; // "object" | "string" | "number" | "boolean" | "enum" | "enum-multi"
value: unknown; // object for kind:"object", else a scalar/array
setValue: (next: unknown) => void;
setField: (key: string, next: unknown) => void; // set one property of an object value
fields: SchemaField[]; // derived from a flat object schema (empty for scalar kinds)
options?: string[]; // for a top-level enum / enum-multi
errors: Record<string, string>; // path-keyed ("value" for a scalar)
canSubmit: boolean;
canSkip: boolean; // true when the suspension permits "skip"
submit: () => Promise<void>; // validate, then resolve with action:"submit"
skip: () => Promise<void>; // resolve with action:"skip" (no payload)
isResolving: boolean;
resolved: boolean;
resolution?: SuspensionStatus;
outcome: ApprovalOutcome; // icon + label for the resolved receipt
error: string | null;
}
When the schema is richer than a flat object of scalars/enums (nested objects, arrays of objects, unions), fields is empty — render a custom component named via the suspension's render.component hint instead.
QuestionRenderer, SelectionRenderer, SchemaFormRenderer
The default cards for human_input suspensions, all thin views over useSuspensionForm:
QuestionRenderer— a free-text answer (no flat schema).SelectionRenderer— single choice (az.enum) or multi (z.array(z.enum)).SchemaFormRenderer— a flat object of scalars and enums, one control per property.
ItemRenderer auto-picks one for a suspension item with reason: "human_input" by render.component hint → reason → resumeSchema shape. A registered renderers.suspension overrides this, and renderers.suspension: false suppresses inline cards. human_approval suspensions still get ApprovalRenderer.
import { QuestionRenderer, SelectionRenderer, SchemaFormRenderer } from "@flow-state-dev/react";
useApproval
Headless controller for a suspension approval — the logic with no markup. Owns the resume transport, in-flight/error state, the duplicate-resume guard, and the resolved outcome.
import { useApproval } from "@flow-state-dev/react";
function MyApproval({ item }) {
const a = useApproval(item);
if (a.resolved) return <span>{a.outcome.icon} {a.outcome.label}</span>;
return (
<>
<button disabled={!a.canApprove || a.isResolving} onClick={a.approve}>Approve</button>
<button disabled={!a.canReject || a.isResolving} onClick={a.reject}>Reject</button>
</>
);
}
Returns { approve, reject, pendingAction, isResolving, error, resolved, resolvedStatus, outcome, canApprove, canReject }. Resolution goes through onApprove/onReject if supplied, else the nearest <SuspensionResolverProvider> (streaming), else a self-contained recovery client (needs flowKind on <FlowProvider>).
ApprovalRenderer
The minimal built-in default that ItemRenderer uses for type === "suspension" items — plain, unstyled buttons so a suspension renders something actionable with zero setup, collapsing to a one-line text receipt once resolved. For a polished, themeable card, register the Approval component from @flow-state-dev/ui via the suspension renderer slot (it's in chatAssistantRenderers). Both are thin views over useApproval.
import { ApprovalRenderer } from "@flow-state-dev/react";
// Used automatically by ItemRenderer; or render directly with explicit handlers:
<ApprovalRenderer
item={suspensionItem}
onApprove={(data) => approve(item.suspensionId, data)}
onReject={(data) => reject(item.suspensionId, data)}
/>
When used inline (inside <ItemsRenderer> without explicit handlers), it reads FlowContext for flowKind/baseUrl/userId and resumes directly. ItemsRenderer threads the resolution outcome to this default, so a reloaded conversation shows whether it was approved or rejected. Suppress it with renderers={{ suspension: false }}.
See Suspensions and approvals for usage patterns.