@novoagents/react 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Robert Pinckney. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") are
4
+ proprietary and confidential. No license, express or implied, is granted
5
+ to any party to use, copy, modify, merge, publish, distribute, sublicense,
6
+ or sell copies of the Software without the prior written permission of
7
+ the copyright holder.
8
+
9
+ Unauthorized use, reproduction, or distribution of the Software, or any
10
+ portion of it, is strictly prohibited and may result in civil and criminal
11
+ penalties.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
+ THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
17
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
18
+ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @novoagents/react
2
+
3
+ Headless React hooks for rendering Novo Agents runs in the browser. The hooks
4
+ manage state and render nothing — bring your own components. This package
5
+ never accepts an API key: it talks to **your** server routes, which proxy to
6
+ `api.novoagents.ai` with the server-only `novoagents` SDK. Its only runtime
7
+ dependency is the browser-safe `novoagents/browser` entry point.
8
+
9
+ ## Hooks
10
+
11
+ - `useNovoRun({ transport })` — consumes the run stream through your proxy
12
+ routes and returns live UI state: `messages`, `status`,
13
+ `pendingInteractions`, `usage`, and `resume()`. One component instance keeps
14
+ exactly one store and one stream consumption, so an inline `transport`
15
+ object literal is safe across re-renders; pass a new `runKey` to
16
+ intentionally reset. Reconnects until the `data-novo-terminal` chunk (a
17
+ closed connection is not a finished run), with jittered exponential backoff
18
+ between unproductive reconnects.
19
+ - `useNovoInteractions({ endpoint, run })` — sources pending
20
+ approvals/questions from the same stream state, POSTs the batch resolution
21
+ shape to your proxied resolve route, and calls the run's `resume()` after a
22
+ successful resolution so the parked stream picks back up from the last seen
23
+ event id.
24
+
25
+ See the full guide with proxy routes and a working component:
26
+ [Build a React run UI](https://novoagents.ai/docs/guides/build-a-react-run-ui).
27
+
28
+ ## Publishing
29
+
30
+ Publish with `pnpm publish` (not `npm publish`): the `workspace:*` dependency
31
+ on `novoagents` is rewritten to the real version only by pnpm's pack step.
@@ -0,0 +1,2 @@
1
+ export { createNovoRunStore, useNovoRun, type NovoRunStore, type UseNovoRunOptions, type UseNovoRunResult, } from './useNovoRun.js';
2
+ export { resolveNovoInteractions, useNovoInteractions, type NovoInteractionResolutionItem, type NovoInteractionResolutionOutcome, type NovoInteractionRunSource, type NovoPendingInteraction, } from './useNovoInteractions.js';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createNovoRunStore, useNovoRun, } from './useNovoRun.js';
2
+ export { resolveNovoInteractions, useNovoInteractions, } from './useNovoInteractions.js';
@@ -0,0 +1,48 @@
1
+ import type { NovoUiPendingInteraction } from 'novoagents/browser';
2
+ export type NovoPendingInteraction = {
3
+ id: string;
4
+ kind: 'approval_request' | 'question';
5
+ status: 'pending' | 'resuming';
6
+ } & Record<string, unknown>;
7
+ export type NovoInteractionResolutionItem = {
8
+ id: string;
9
+ inputResponses?: Array<Record<string, unknown>>;
10
+ cancel?: boolean;
11
+ reason?: string;
12
+ };
13
+ export type NovoInteractionResolutionOutcome = {
14
+ id: string;
15
+ status: 'resolved' | 'already_resolved' | 'conflict' | 'not_found' | 'invalid_body';
16
+ };
17
+ /**
18
+ * The slice of a `useNovoRun` result (or `NovoRunStore`-shaped object) that
19
+ * interactions can be sourced from: the reducer's pending queue plus the
20
+ * resume path invoked after a successful resolution.
21
+ */
22
+ export type NovoInteractionRunSource = {
23
+ pendingInteractions: readonly NovoUiPendingInteraction[];
24
+ resume?: () => Promise<void>;
25
+ };
26
+ export declare function resolveNovoInteractions(input: {
27
+ endpoint: string;
28
+ items: NovoInteractionResolutionItem[];
29
+ fetch?: typeof globalThis.fetch;
30
+ }): Promise<NovoInteractionResolutionOutcome[]>;
31
+ /**
32
+ * Track and resolve the run's pending approvals/questions through your proxied
33
+ * batch resolve route. Zero-config source: pass the `useNovoRun` result as
34
+ * `run` and pending interactions come from the stream reducer, with the run's
35
+ * `resume()` invoked automatically once a resolution lands. An explicit
36
+ * `interactions` array (e.g. from your own discovery endpoint) overrides the
37
+ * run source.
38
+ */
39
+ export declare function useNovoInteractions(input: {
40
+ endpoint: string;
41
+ run?: NovoInteractionRunSource;
42
+ interactions?: NovoPendingInteraction[];
43
+ fetch?: typeof globalThis.fetch;
44
+ }): {
45
+ interactions: NovoPendingInteraction[];
46
+ resolve(items: NovoInteractionResolutionItem[]): Promise<NovoInteractionResolutionOutcome[]>;
47
+ error: Error | undefined;
48
+ };
@@ -0,0 +1,68 @@
1
+ import { useCallback, useState } from 'react';
2
+ export async function resolveNovoInteractions(input) {
3
+ const request = input.fetch ?? globalThis.fetch;
4
+ const response = await request(input.endpoint, {
5
+ method: 'POST',
6
+ headers: { 'Content-Type': 'application/json' },
7
+ body: JSON.stringify({ items: input.items }),
8
+ });
9
+ const body = (await response.json());
10
+ if (!response.ok) {
11
+ throw new Error(body.error?.message ?? `Interaction resolve failed (${response.status}).`);
12
+ }
13
+ return body.items ?? [];
14
+ }
15
+ /**
16
+ * Track and resolve the run's pending approvals/questions through your proxied
17
+ * batch resolve route. Zero-config source: pass the `useNovoRun` result as
18
+ * `run` and pending interactions come from the stream reducer, with the run's
19
+ * `resume()` invoked automatically once a resolution lands. An explicit
20
+ * `interactions` array (e.g. from your own discovery endpoint) overrides the
21
+ * run source.
22
+ */
23
+ export function useNovoInteractions(input) {
24
+ const [resolvedIds, setResolvedIds] = useState(() => new Set());
25
+ const [error, setError] = useState();
26
+ const resume = input.run?.resume;
27
+ const resolve = useCallback(async (items) => {
28
+ try {
29
+ const outcomes = await resolveNovoInteractions({
30
+ endpoint: input.endpoint,
31
+ items,
32
+ ...(input.fetch ? { fetch: input.fetch } : {}),
33
+ });
34
+ const resolved = new Set(outcomes
35
+ .filter((item) => item.status === 'resolved' ||
36
+ item.status === 'already_resolved')
37
+ .map((item) => item.id));
38
+ if (resolved.size > 0) {
39
+ setResolvedIds((current) => new Set([...current, ...resolved]));
40
+ // The run resumed (or will resume) server-side; restart stream
41
+ // consumption so the resolved chunk and subsequent output arrive.
42
+ void resume?.();
43
+ }
44
+ setError(undefined);
45
+ return outcomes;
46
+ }
47
+ catch (caught) {
48
+ const next = caught instanceof Error
49
+ ? caught
50
+ : new Error('Interaction resolve failed.');
51
+ setError(next);
52
+ throw next;
53
+ }
54
+ }, [input.endpoint, input.fetch, resume]);
55
+ const sourced = input.interactions ??
56
+ (input.run?.pendingInteractions ?? []).map((interaction) => ({
57
+ ...interaction,
58
+ kind: interaction.request.kind,
59
+ status: 'pending',
60
+ }));
61
+ return {
62
+ // resolvedIds bridges the gap between the resolve acknowledgement and the
63
+ // stream's own data-novo-input-resolved chunk removing the entry.
64
+ interactions: sourced.filter((interaction) => !resolvedIds.has(interaction.id)),
65
+ resolve,
66
+ error,
67
+ };
68
+ }
@@ -0,0 +1,41 @@
1
+ import { type BrowserRunTransport, type NovoAgentEvent, type NovoRunUiState } from 'novoagents/browser';
2
+ export type NovoRunStore = {
3
+ subscribe(listener: () => void): () => void;
4
+ getSnapshot(): NovoRunUiState;
5
+ /**
6
+ * Begin consuming the run stream. Idempotent: repeat calls return the
7
+ * in-flight (or last) consumption instead of starting another.
8
+ */
9
+ start(): Promise<void>;
10
+ /**
11
+ * Restart consumption from the last seen event id. No-op while consumption
12
+ * is active or after the terminal chunk; the restart path exists because a
13
+ * pause (`waiting_for_approval` / `waiting_for_input`) ends consumption —
14
+ * call this after resolving interactions so the resumed run streams again.
15
+ * Also retries after a stream failure.
16
+ */
17
+ resume(): Promise<void>;
18
+ };
19
+ export declare function createNovoRunStore(transport: BrowserRunTransport<NovoAgentEvent> | (() => BrowserRunTransport<NovoAgentEvent>)): NovoRunStore;
20
+ export type UseNovoRunOptions = {
21
+ transport: BrowserRunTransport<NovoAgentEvent>;
22
+ autoStart?: boolean;
23
+ /**
24
+ * Escape hatch for intentional resets: a new key discards the store and
25
+ * consumes the stream from scratch. The abandoned consumption is not
26
+ * cancelled — it stops at the run's next pause or terminal.
27
+ */
28
+ runKey?: string | number;
29
+ };
30
+ export type UseNovoRunResult = NovoRunUiState & {
31
+ /** See {@link NovoRunStore.resume}. */
32
+ resume(): Promise<void>;
33
+ store: NovoRunStore;
34
+ };
35
+ /**
36
+ * Consume a run stream through your proxy transport and expose the reduced UI
37
+ * state. One store (one stream consumption) per component instance: passing an
38
+ * inline `transport` object literal is safe across re-renders — the latest
39
+ * transport is read through a ref without re-creating the store.
40
+ */
41
+ export declare function useNovoRun(input: UseNovoRunOptions): UseNovoRunResult;
@@ -0,0 +1,107 @@
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
2
+ import { consumeRunWithReconnect, createNovoRunUiReducer, } from 'novoagents/browser';
3
+ export function createNovoRunStore(transport) {
4
+ const currentTransport = typeof transport === 'function' ? transport : () => transport;
5
+ const reducer = createNovoRunUiReducer();
6
+ const listeners = new Set();
7
+ let state = reducer.getState();
8
+ let lastEventId;
9
+ let active;
10
+ let latest;
11
+ let reachedTerminal = false;
12
+ const publish = (next) => {
13
+ state = next;
14
+ for (const listener of listeners)
15
+ listener();
16
+ };
17
+ const consume = () => {
18
+ const promise = consumeRunWithReconnect({
19
+ // Read the transport per call so the latest closure is always used.
20
+ transport: {
21
+ connect: (fromEventId) => currentTransport().connect(fromEventId),
22
+ pollRun: () => currentTransport().pollRun(),
23
+ },
24
+ ...(lastEventId !== undefined ? { initialLastEventId: lastEventId } : {}),
25
+ isTerminalEvent: (event) => event.type === 'data-novo-terminal',
26
+ onFrame: ({ id, event }) => {
27
+ if (id !== undefined)
28
+ lastEventId = id;
29
+ if (event)
30
+ publish(reducer.reduce(event));
31
+ },
32
+ onPoll: (run) => {
33
+ if (run.status === 'waiting_for_approval' ||
34
+ run.status === 'waiting_for_input') {
35
+ publish({ ...state, status: run.status });
36
+ }
37
+ },
38
+ })
39
+ .then((result) => {
40
+ if (result.reachedTerminal)
41
+ reachedTerminal = true;
42
+ }, (error) => {
43
+ publish({
44
+ ...state,
45
+ status: 'failed',
46
+ error: {
47
+ type: 'api_error',
48
+ code: 'stream_error',
49
+ message: error instanceof Error ? error.message : 'Stream failed.',
50
+ },
51
+ });
52
+ })
53
+ .then(() => {
54
+ if (active === promise)
55
+ active = undefined;
56
+ });
57
+ active = promise;
58
+ latest = promise;
59
+ return promise;
60
+ };
61
+ return {
62
+ subscribe(listener) {
63
+ listeners.add(listener);
64
+ return () => listeners.delete(listener);
65
+ },
66
+ getSnapshot: () => state,
67
+ start() {
68
+ return latest ?? consume();
69
+ },
70
+ resume() {
71
+ if (active)
72
+ return active;
73
+ if (reachedTerminal)
74
+ return latest ?? Promise.resolve();
75
+ return consume();
76
+ },
77
+ };
78
+ }
79
+ /**
80
+ * Consume a run stream through your proxy transport and expose the reduced UI
81
+ * state. One store (one stream consumption) per component instance: passing an
82
+ * inline `transport` object literal is safe across re-renders — the latest
83
+ * transport is read through a ref without re-creating the store.
84
+ */
85
+ export function useNovoRun(input) {
86
+ const transportRef = useRef(input.transport);
87
+ const [entry, setEntry] = useState(() => ({
88
+ key: input.runKey,
89
+ store: createNovoRunStore(() => transportRef.current),
90
+ }));
91
+ if (!Object.is(entry.key, input.runKey)) {
92
+ setEntry({
93
+ key: input.runKey,
94
+ store: createNovoRunStore(() => transportRef.current),
95
+ });
96
+ }
97
+ const store = entry.store;
98
+ useEffect(() => {
99
+ transportRef.current = input.transport;
100
+ });
101
+ useEffect(() => {
102
+ if (input.autoStart !== false)
103
+ void store.start();
104
+ }, [input.autoStart, store]);
105
+ const state = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
106
+ return useMemo(() => ({ ...state, resume: store.resume, store }), [state, store]);
107
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@novoagents/react",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Browser-safe React hooks for Novo Agents streams and interactions.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist/**/*.js",
11
+ "dist/**/*.d.ts",
12
+ "README.md"
13
+ ],
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "novoagents": "0.3.0-alpha.51"
25
+ },
26
+ "peerDependencies": {
27
+ "react": ">=18"
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "19.2.17",
31
+ "@types/react-dom": "19.2.3",
32
+ "happy-dom": "20.0.11",
33
+ "react": "19.2.7",
34
+ "react-dom": "19.2.7",
35
+ "typescript": "6.0.3",
36
+ "vite": "8.1.4",
37
+ "vitest": "5.0.0-beta.6"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "clean": "rm -rf dist",
42
+ "test": "vitest run",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit"
44
+ }
45
+ }