Put runtime output in a React application
For Claude, Codex, AGY, or OpenCode, keep the provider client on your server. Call your application's request endpoint, render its returned run state/text, or deliver stream deltas through your application's chosen transport. The package does not provide a universal runtime chat hook.
Submit through your application's server
Mount the documented handler on your authenticated application POST route. Keep user authorization, ownership, and CSRF policy at that server boundary. Pass the route URL to AnswerForm; keep provider keys and model selection on the server. The browser sends only { input }.
Download react-answer.tsx into your React app. Render AnswerForm with an endpoint pointing to your application's route. It uses ordinary React hooks and browser fetch; it does not create a provider client or gateway connection in the browser.
import React, { useEffect, useId, useRef, useState } from "react";
type AnswerState =
| { kind: "idle" | "loading" | "cancelled" }
| { kind: "pending"; runId: string; status: string }
| { kind: "completed"; runId: string; text: string }
| { kind: "failed"; message: string; runId?: string };
export function useAnswerRequest(endpoint: string, fetchImpl: typeof fetch = fetch) {
const [state, setState] = useState<AnswerState>({ kind: "idle" });
const active = useRef<AbortController | null>(null);
useEffect(() => {
setState({ kind: "idle" });
return () => {
const controller = active.current;
active.current = null;
controller?.abort();
};
}, [endpoint, fetchImpl]);
async function submit(input: string) {
active.current?.abort();
active.current = null;
if (!input.trim()) {
setState({ kind: "failed", message: "Provide a question." });
return;
}
const controller = new AbortController();
active.current = controller;
setState({ kind: "loading" });
try {
const response = await fetchImpl(endpoint, {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input }),
signal: controller.signal,
});
const body: unknown = await response.json();
if (active.current !== controller || controller.signal.aborted) return;
const payload = body && typeof body === "object" && !Array.isArray(body)
? body as Record<string, unknown> : {};
const runId = typeof payload.runId === "string" && payload.runId.trim() ? payload.runId : undefined;
if (!response.ok) {
const message = response.status === 400 ? "Provide a valid question."
: [401, 403].includes(response.status) ? "This request is not allowed."
: response.status === 503 ? "The runtime is unavailable. Check existing work before retrying."
: "The answer request failed.";
setState({ kind: "failed", message, runId });
} else if (response.status === 202 && runId && typeof payload.status === "string" &&
["started", "running", "stopping"].includes(payload.status)) {
setState({ kind: "pending", runId, status: payload.status });
} else if (response.status === 200 && runId && payload.status === "completed" && typeof payload.text === "string") {
setState({ kind: "completed", runId, text: payload.text });
} else {
setState({ kind: "failed", runId, message: "The server returned an unexpected run result." });
}
} catch {
if (active.current === controller && !controller.signal.aborted) {
setState({ kind: "failed", message: "Could not confirm the request result. Check existing work before retrying." });
}
} finally {
if (active.current === controller) active.current = null;
}
}
function cancel() {
const controller = active.current;
if (!controller) return;
active.current = null;
controller.abort();
setState({ kind: "cancelled" });
}
return { state, submit, cancel };
}
export function AnswerForm({ endpoint, fetchImpl }: { endpoint: string; fetchImpl?: typeof fetch }) {
const { state, submit, cancel } = useAnswerRequest(endpoint, fetchImpl);
const question = useRef<HTMLTextAreaElement>(null);
const inputId = useId();
return (
<section aria-label="Ask the runtime">
<form onSubmit={(event) => { event.preventDefault(); void submit(question.current?.value ?? ""); }}>
<label htmlFor={inputId}>Question</label>
<textarea id={inputId} name="question" ref={question} required />
<button type="submit" disabled={state.kind === "loading"}>Ask</button>
{state.kind === "loading" && <button type="button" onClick={cancel}>Stop waiting</button>}
</form>
<div aria-live="polite" aria-busy={state.kind === "loading"}>
{state.kind === "idle" && <p>Ask a question to start.</p>}
{state.kind === "loading" && <p role="status">Waiting for the server…</p>}
{state.kind === "cancelled" && <p role="status">Stopped waiting locally. Server work may continue.</p>}
{state.kind === "pending" && <p role="status">Pending run {state.runId} ({state.status}). Keep this ID to resume through your application.</p>}
{state.kind === "failed" && <React.Fragment><p role="alert">{state.message}</p>{state.runId && <p>Run {state.runId}</p>}</React.Fragment>}
{state.kind === "completed" && <React.Fragment>
<p>Completed run {state.runId}.</p>
{state.text === "" ? <p>The completed answer is empty.</p> : <pre>{state.text}</pre>}
</React.Fragment>}
</div>
</section>
);
}| UI state | Meaning |
|---|---|
| Loading | One browser request is awaiting a result; duplicate form submission is disabled |
| Completed | HTTP 200 supplied a completed run, a run ID, and text; an explicit empty string remains valid |
| Pending | HTTP 202 supplied an active run; retain its ID and resume through your authorized application endpoint |
| Failed | A controlled HTTP failure or unexpected response; raw backend messages are not rendered |
| Cancelled | The browser stopped waiting locally; server work can continue |
The helper validates the handler's response envelope before displaying an answer. It keeps a failed run's ID when available and maps HTTP errors to fixed application messages. Unknown exceptions and invalid JSON remain failures; neither is converted to an empty answer. It performs no automatic retries.
Stop waiting aborts the local fetch and invalidates its response. A cancelled, older, or unmounted request cannot overwrite a newer answer, even if the fetch implementation ignores its signal. Changing the endpoint or injected fetch implementation also aborts the old request and resets the UI.
Local abort does not cancel backend execution. Persist run ownership and IDs on the server so work can be reconciled if the browser never receives its response. This form does not poll pending runs or submit cancellation requests; connect those actions to your application's authorized run workflow. Keep the optional fetchImpl stable while mounted; it is useful for tests or an application-owned transport wrapper.
Run the React workflow tests
Download react-answer.test.tsx, react-answer.tsx, and server-handler.ts together into examples/. Start with the ESM test workspace from consumer testing. The validated test dependencies below require Node 20.19+, 22.13+, or 24+:
npm install react@19 react-dom@19
npm install --save-dev vitest@4 jsdom@29 @types/react @types/react-dom
npx vitest run examples/react-answer.test.tsx --environment jsdom --maxWorkers 1Expected result: seven passing tests without real provider credentials or network calls. The tests mount the form in React Strict Mode. Native Codex fixtures run through the installed adapter, facade, and server handler before the UI displays their responses. Deferred browser responses check loading, local cancellation, stale completion, endpoint changes, and unmount cleanup. Each test unmounts its React roots and disposes its server-owned client.
Gateway connection bindings
For a gateway that speaks the bindings' WebSocket protocol, install React 18 or later and import @cavi-ai/api-client/frameworks/react. These bindings manage a gateway connection; connection success is not run completion. They are not an adapter for every gateway's transport, including Hermes' dashboard JSON-RPC channel.
Pass your gateway URL, browser-user token, and accepted client identity into the mounted provider. Handle invalid addresses and handshake failures as visible state. Download GatewayApp.
import { GatewayClientProvider, useGatewayClientContext } from "@cavi-ai/api-client/frameworks/react";
function ConnectionStatus() {
const { state, urlError, connectionError } = useGatewayClientContext();
if (urlError) return <p role="alert">Invalid gateway address.</p>;
if (connectionError) return <p role="alert">Gateway connection failed.</p>;
return <p role="status">Gateway: {state}</p>;
}
export function GatewayApp(config: { gatewayUrl: string; token: string; clientId: string }) {
return (
<GatewayClientProvider gatewayBaseUrl={config.gatewayUrl} authToken={config.token} clientId={config.clientId}>
<ConnectionStatus />
</GatewayClientProvider>
);
}Use credentials scoped to the browser user and permissions allowed by the gateway. A provider API key does not belong in these props. The gateway must accept the browser origin, client identity, and any requested scopes.
Let the mounted provider own the connection lifecycle. Do not create a second facade connection just to read socket state. If your application also runs work through a separately owned facade, use its result and terminal outcome for execution state.
The root entry does not import React. React declarations · Gateway resources
Referenced examples
Verified example · TSX
React answer
examples/react-answer.tsx
import React, { useEffect, useId, useRef, useState } from "react";
type AnswerState =
| { kind: "idle" | "loading" | "cancelled" }
| { kind: "pending"; runId: string; status: string }
| { kind: "completed"; runId: string; text: string }
| { kind: "failed"; message: string; runId?: string };
export function useAnswerRequest(endpoint: string, fetchImpl: typeof fetch = fetch) {
const [state, setState] = useState<AnswerState>({ kind: "idle" });
const active = useRef<AbortController | null>(null);
useEffect(() => {
setState({ kind: "idle" });
return () => {
const controller = active.current;
active.current = null;
controller?.abort();
};
}, [endpoint, fetchImpl]);
async function submit(input: string) {
active.current?.abort();
active.current = null;
if (!input.trim()) {
setState({ kind: "failed", message: "Provide a question." });
return;
}
const controller = new AbortController();
active.current = controller;
setState({ kind: "loading" });
try {
const response = await fetchImpl(endpoint, {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input }),
signal: controller.signal,
});
const body: unknown = await response.json();
if (active.current !== controller || controller.signal.aborted) return;
const payload = body && typeof body === "object" && !Array.isArray(body)
? body as Record<string, unknown> : {};
const runId = typeof payload.runId === "string" && payload.runId.trim() ? payload.runId : undefined;
if (!response.ok) {
const message = response.status === 400 ? "Provide a valid question."
: [401, 403].includes(response.status) ? "This request is not allowed."
: response.status === 503 ? "The runtime is unavailable. Check existing work before retrying."
: "The answer request failed.";
setState({ kind: "failed", message, runId });
} else if (response.status === 202 && runId && typeof payload.status === "string" &&
["started", "running", "stopping"].includes(payload.status)) {
setState({ kind: "pending", runId, status: payload.status });
} else if (response.status === 200 && runId && payload.status === "completed" && typeof payload.text === "string") {
setState({ kind: "completed", runId, text: payload.text });
} else {
setState({ kind: "failed", runId, message: "The server returned an unexpected run result." });
}
} catch {
if (active.current === controller && !controller.signal.aborted) {
setState({ kind: "failed", message: "Could not confirm the request result. Check existing work before retrying." });
}
} finally {
if (active.current === controller) active.current = null;
}
}
function cancel() {
const controller = active.current;
if (!controller) return;
active.current = null;
controller.abort();
setState({ kind: "cancelled" });
}
return { state, submit, cancel };
}
export function AnswerForm({ endpoint, fetchImpl }: { endpoint: string; fetchImpl?: typeof fetch }) {
const { state, submit, cancel } = useAnswerRequest(endpoint, fetchImpl);
const question = useRef<HTMLTextAreaElement>(null);
const inputId = useId();
return (
<section aria-label="Ask the runtime">
<form onSubmit={(event) => { event.preventDefault(); void submit(question.current?.value ?? ""); }}>
<label htmlFor={inputId}>Question</label>
<textarea id={inputId} name="question" ref={question} required />
<button type="submit" disabled={state.kind === "loading"}>Ask</button>
{state.kind === "loading" && <button type="button" onClick={cancel}>Stop waiting</button>}
</form>
<div aria-live="polite" aria-busy={state.kind === "loading"}>
{state.kind === "idle" && <p>Ask a question to start.</p>}
{state.kind === "loading" && <p role="status">Waiting for the server…</p>}
{state.kind === "cancelled" && <p role="status">Stopped waiting locally. Server work may continue.</p>}
{state.kind === "pending" && <p role="status">Pending run {state.runId} ({state.status}). Keep this ID to resume through your application.</p>}
{state.kind === "failed" && <React.Fragment><p role="alert">{state.message}</p>{state.runId && <p>Run {state.runId}</p>}</React.Fragment>}
{state.kind === "completed" && <React.Fragment>
<p>Completed run {state.runId}.</p>
{state.text === "" ? <p>The completed answer is empty.</p> : <pre>{state.text}</pre>}
</React.Fragment>}
</div>
</section>
);
}
Referenced by
Artifact provenance
- Package
- @cavi-ai/api-client@0.21.0
- Release
- v0.21.0
- Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1- Source
examples/react-answer.tsx- Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558- Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48
Verified example · TSX
React answer.test
examples/react-answer.test.tsx
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { createApiClient, createRuntimeProviderRegistry } from "@cavi-ai/api-client";
import { createCodexProviderModule } from "@cavi-ai/api-client/providers/codex/runtime";
import { AnswerForm } from "./react-answer.js";
import { createRunHandler } from "./server-handler.js";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: { root: Root; element: HTMLDivElement }[] = [];
const clients: { dispose(): Promise<void> }[] = [];
afterEach(async () => {
for (const { root, element } of mounted.splice(0)) {
await act(async () => { root.unmount(); });
element.remove();
}
for (const client of clients.splice(0)) await client.dispose();
});
async function mount(fetchImpl: typeof fetch, endpoint = "/answer") {
const element = document.createElement("div");
document.body.append(element);
const root = createRoot(element);
mounted.push({ root, element });
const render = async (url: string) => {
await act(async () => { root.render(<React.StrictMode><AnswerForm endpoint={url} fetchImpl={fetchImpl} /></React.StrictMode>); });
};
await render(endpoint);
const submit = (input = "Question") => {
element.querySelector("textarea")!.value = input;
element.querySelector("form")!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
};
const cancel = () => element.querySelector<HTMLButtonElement>('button[type="button"]')!.click();
return { element, root, render, submit, cancel };
}
function deferred() {
let resolve!: (response: Response) => void;
const promise = new Promise<Response>((done) => { resolve = done; });
return { promise, resolve };
}
function server(reply: () => Response) {
const providerBodies: unknown[] = [];
const browserBodies: unknown[] = [];
const client = createApiClient("codex", {
registry: createRuntimeProviderRegistry({ modules: [createCodexProviderModule({ apiKey: "server-fixture-key" })] }),
fetchImpl: async (_url, init) => {
providerBodies.push(JSON.parse(String(init?.body)));
return reply();
},
});
clients.push(client);
const handle = createRunHandler(client, "server-model", () => {});
let response: Promise<Response>;
const fetchImpl: typeof fetch = (url, init) => {
browserBodies.push(JSON.parse(String(init?.body)));
response = handle(new Request(new URL(String(url), "https://application.example"), init));
return response;
};
return { fetchImpl, providerBodies, browserBodies, response: () => response };
}
describe("React answer workflow", () => {
it("renders the real handler's answer and keeps model and credentials on the server", async () => {
const app = server(() => Response.json({ id: "run-1", status: "completed", output_text: "Answer" }));
const ui = await mount(app.fetchImpl);
await act(async () => { ui.submit(); await app.response(); });
expect(ui.element.querySelector("pre")?.textContent).toBe("Answer");
expect(ui.element.textContent).toContain("Completed run run-1");
expect(app.browserBodies).toEqual([{ input: "Question" }]);
expect(app.providerBodies).toMatchObject([{ input: "Question", model: "server-model" }]);
});
it("keeps an active run pending without polling or displaying a completed answer", async () => {
const app = server(() => Response.json({ id: "run-1", status: "queued" }));
const ui = await mount(app.fetchImpl);
await act(async () => { ui.submit(); await app.response(); });
expect(ui.element.textContent).toContain("Pending run run-1 (started)");
expect(ui.element.querySelector("pre")).toBeNull();
expect(app.browserBodies).toHaveLength(1);
});
it("renders controlled failures without exposing provider diagnostics", async () => {
const app = server(() => Response.json({ error: { message: "Private credential diagnostic" } }, { status: 401 }));
const ui = await mount(app.fetchImpl);
await act(async () => { ui.submit(); await app.response(); });
expect(ui.element.querySelector('[role="alert"]')?.textContent).toContain("runtime is unavailable");
expect(ui.element.textContent).not.toContain("Private");
expect(ui.element.textContent).not.toContain("server-fixture-key");
});
it("rejects missing text and mismatched success states, preserving a failed run ID", async () => {
let reply = Response.json({ runId: "run-1", status: "completed" });
const ui = await mount(async () => reply);
for (const response of [reply, Response.json({ runId: "run-1", status: "completed", text: "Wrong state" }, { status: 202 })]) {
reply = response;
await act(async () => { ui.submit(); });
expect(ui.element.querySelector('[role="alert"]')).not.toBeNull();
expect(ui.element.querySelector("pre")).toBeNull();
}
reply = Response.json({ runId: "failed-1", status: "failed", error: "Private diagnostic" }, { status: 502 });
await act(async () => { ui.submit(); });
expect(ui.element.textContent).toContain("failed-1");
expect(ui.element.textContent).not.toContain("Private diagnostic");
});
it("ignores an older response after local cancellation and a newer submission", async () => {
const older = deferred();
const newer = deferred();
const signals: AbortSignal[] = [];
const ui = await mount(async (_url, init) => {
signals.push(init!.signal!);
return signals.length === 1 ? older.promise : newer.promise;
});
await act(async () => { ui.submit("Old"); });
expect(ui.element.querySelector<HTMLButtonElement>('button[type="submit"]')!.disabled).toBe(true);
await act(async () => { ui.cancel(); ui.submit("New"); });
expect(signals[0].aborted).toBe(true);
await act(async () => { newer.resolve(Response.json({ runId: "new-1", status: "completed", text: "New answer" })); });
await act(async () => { older.resolve(Response.json({ runId: "old-1", status: "completed", text: "Old answer" })); });
expect(ui.element.querySelector("pre")?.textContent).toBe("New answer");
});
it("keeps local cancellation visible when an ignored abort still produces a response", async () => {
const pending = deferred();
let signal: AbortSignal | undefined;
let calls = 0;
const ui = await mount(async (_url, init) => { calls += 1; signal = init!.signal!; return pending.promise; });
await act(async () => { ui.submit(); });
await act(async () => { ui.cancel(); });
expect(signal!.aborted).toBe(true);
expect(ui.element.textContent).toContain("Stopped waiting locally");
await act(async () => { pending.resolve(Response.json({ runId: "late-1", status: "completed", text: "Late answer" })); });
expect(ui.element.textContent).not.toContain("Late answer");
expect(ui.element.textContent).toContain("Server work may continue");
expect(calls).toBe(1);
});
it("aborts and invalidates requests when the endpoint changes or the component unmounts", async () => {
const pending = deferred();
const signals: AbortSignal[] = [];
const ui = await mount(async (_url, init) => { signals.push(init!.signal!); return pending.promise; });
await act(async () => { ui.submit(); });
await ui.render("/other-answer");
expect(signals[0].aborted).toBe(true);
expect(ui.element.textContent).toContain("Ask a question to start");
await act(async () => { pending.resolve(Response.json({ runId: "stale-1", status: "completed", text: "Stale answer" })); });
expect(ui.element.querySelector("pre")).toBeNull();
const last = deferred();
const unmounted = await mount(async (_url, init) => { signals.push(init!.signal!); return last.promise; });
await act(async () => { unmounted.submit(); });
await act(async () => { unmounted.root.unmount(); });
mounted.splice(mounted.findIndex((entry) => entry.root === unmounted.root), 1);
expect(signals.at(-1)!.aborted).toBe(true);
await act(async () => { last.resolve(Response.json({ runId: "late-1", status: "completed", text: "Late answer" })); });
expect(unmounted.element.textContent).toBe("");
unmounted.element.remove();
});
});
Package imports
Referenced by
Artifact provenance
- Package
- @cavi-ai/api-client@0.21.0
- Release
- v0.21.0
- Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1- Source
examples/react-answer.test.tsx- Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558- Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48
Verified example · TypeScript
Server handler
examples/server-handler.ts
import { isAuthError, type CapabilityClient } from "@cavi-ai/api-client";
export function createRunHandler(
client: CapabilityClient,
model: string,
reportError: (error: unknown) => void,
) {
return async function handle(request: Request): Promise<Response> {
if (request.method !== "POST") {
return Response.json({ error: "Use POST." }, { status: 405, headers: { Allow: "POST" } });
}
let body: unknown;
try {
body = await request.json();
} catch {
return Response.json({ error: "Expected JSON." }, { status: 400 });
}
if (!body || typeof body !== "object" || !("input" in body) ||
typeof body.input !== "string" || body.input.trim() === "") {
return Response.json({ error: "Provide a non-empty input string." }, { status: 400 });
}
try {
const result = await client.startRun({ model, input: body.input });
if (!result.ok) {
reportError(result.gap);
return Response.json({ error: "Run unavailable.", reason: result.gap.reason }, { status: 503 });
}
const run = result.data;
const active = ["started", "running", "stopping"].includes(run.status);
const status = active ? 202 : run.status === "completed" ? 200 : 502;
return Response.json({
runId: run.run_id,
status: run.status,
text: run.output ?? run.response,
tokens: run.tokens,
}, { status });
} catch (error) {
reportError(error);
if (isAuthError(error)) {
// These are server-owned provider credentials, not the caller's login.
return Response.json({ error: "Runtime authentication unavailable." }, { status: 503 });
}
return Response.json({ error: "Run request failed." }, { status: 502 });
}
};
}
Package imports
Referenced by
Artifact provenance
- Package
- @cavi-ai/api-client@0.21.0
- Release
- v0.21.0
- Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1- Source
examples/server-handler.ts- Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558- Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48
Verified example · TSX
React gateway
examples/react-gateway.tsx
import { GatewayClientProvider, useGatewayClientContext } from "@cavi-ai/api-client/frameworks/react";
function ConnectionStatus() {
const { state, urlError, connectionError } = useGatewayClientContext();
if (urlError) return <p role="alert">Invalid gateway address.</p>;
if (connectionError) return <p role="alert">Gateway connection failed.</p>;
return <p role="status">Gateway: {state}</p>;
}
export function GatewayApp(config: { gatewayUrl: string; token: string; clientId: string }) {
return (
<GatewayClientProvider gatewayBaseUrl={config.gatewayUrl} authToken={config.token} clientId={config.clientId}>
<ConnectionStatus />
</GatewayClientProvider>
);
}
Package imports
Referenced by
Artifact provenance
- Package
- @cavi-ai/api-client@0.21.0
- Release
- v0.21.0
- Commit
07f0ee45424c267278f7b0c89a7dcea4e2492cc1- Source
examples/react-gateway.tsx- Tarball SHA-256
0924b5d3a7a40869bd44fedc47a9b8eb9844c2af1fd5c7ca77469d8b7c0b8558- Content SHA-256
24eff463c41582eaf5e70c58f42e5ae13bd12d02fa14ae2a886afa3abf5cdf48