Build a React run UI
Novo has three browser integration tiers:
createNovoMessageSession/createNovoRunReducerfor synchronous replay and custom transports.createNovoRunControllerfor fetch, reconnect, persistence, cancellation, and interaction lifecycle without a framework.@novoagents/reactfor effect ownership and selected React subscriptions.
All three are headless and browser-safe. They never accept a Novo API key; your server routes authenticate the user and proxy the server-only SDK.
Proxy routes
Expose a resumable stream, authoritative run status, and interaction resolver:
// app/api/novo-runs/[runId]/stream/route.ts
import { NovoAgents } from 'novoagents';
const novo = new NovoAgents();
type Ctx = { params: Promise<{ runId: string }> };
export const maxDuration = 1800;
export async function GET(request: Request, { params }: Ctx) {
const { runId } = await params;
const replay = await novo.runs.stream(runId);
const cursor = request.headers.get('Last-Event-Id') ?? undefined;
const stream = cursor ? await replay.reconnect({ lastEventId: cursor }) : replay;
if (stream !== replay) replay.close();
return stream.toResponse();
}
// app/api/novo-runs/[runId]/route.ts
export async function GET(_: Request, { params }: Ctx) {
return Response.json(await novo.runs.get((await params).runId));
}
// app/api/novo-runs/[runId]/interactions/resolve/route.ts
export async function POST(request: Request, { params }: Ctx) {
return Response.json(
await novo.interactions.resolveBatch(
(await params).runId,
await request.json(),
),
);
}
Add your own authorization to every route.
Controller and hooks
'use client';
import { useNovoInteractions, useNovoRun } from '@novoagents/react';
import {
createHttpInteractionResolveTransport,
createNovoHttpRunSource,
createNovoRunController,
} from 'novoagents/browser';
export function RunView({ runId }: { runId: string }) {
const run = useNovoRun({
runKey: runId,
createController: () =>
createNovoRunController({
source: createNovoHttpRunSource({
url: `/api/novo-runs/${runId}/stream`,
pollUrl: `/api/novo-runs/${runId}`,
}),
interactions: createHttpInteractionResolveTransport({
endpoint: `/api/novo-runs/${runId}/interactions/resolve`,
}),
}),
});
const inbox = useNovoInteractions(run.controller);
return (
<main>
{run.messages.map((message) => (
<article key={message.id}>
{message.parts.map((part) =>
part.type === 'text' ? <p key={part.id}>{part.text}</p> : null,
)}
</article>
))}
{inbox.interactions.map((interaction) => (
<button
key={interaction.id}
onClick={() =>
inbox.resolve([
{
id: interaction.id,
inputResponses: [
{ requestId: interaction.request.requestId, optionId: 'approve' },
],
},
])
}
>
Approve {String(interaction.toolName)}
</button>
))}
<footer>{run.status} · {run.connection}</footer>
</main>
);
}
useNovoRun creates the controller inside its owning effect. A runKey
change, unmount, or Strict Mode cleanup stops local I/O and permanently disposes
that controller. The wire-reported run.status remains separate from local
connection state: stop() detaches locally; only a configured cancelRun()
transport cancels the server run.
Cursors and persistence
The controller validates SSE ids as non-negative safe integers. It tracks the last frame accepted in memory separately from the last frame persisted. A persistence adapter reconnects from its durable cursor and checkpoints only after an ordered append succeeds.
Hydration can include completeThrough only when your store proves it contains
the whole stream prefix through that cursor. Without that proof, the controller
replays from zero and deterministically rebuilds state; it never guesses from
the largest hydrated id.
Pause, resolve, resume
data-novo-input-requested adds an item to pendingInteractions. The controller
coalesces identical in-flight resolution requests, rejects conflicting local
decisions, preserves nullable server outcome ids, and resumes consumption once
after a successful resolution. The durable data-novo-input-resolved frame
remains the stream record.
If your application also persists interaction requests outside the stream,
configure interactionDiscovery: { list, intervalMs? } on the controller. Its
generation-fenced poller continues while SSE is idle, folds DB-only requests
into the same pending queue, and never revives an interaction resolved by this
controller.
Installing
npm install novoagents@alpha @novoagents/react@alpha
Install the two alpha tags together; @novoagents/react is released against
the matching novoagents/browser controller contract.
See Reconnect a dropped stream and Streaming for the wire contract.