novo agents

Build a React run UI

Novo has three browser integration tiers:

  1. createNovoMessageSession / createNovoRunReducer for synchronous replay and custom transports.
  2. createNovoRunController for fetch, reconnect, persistence, cancellation, and interaction lifecycle without a framework.
  3. @novoagents/react for 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.