Skip to content

Agentic UI Core

Last updated 1 day ago · 2eb7d2e

@reactive-agents/ui-core is the headless core every Reactive Agents web binding shares. It is Effect-free, dependency-free, and browser-safe: it holds all the protocol parsing, stream reconnection, state transitions, and durable-rail request logic, so @reactive-agents/react, /vue, and /svelte are thin reactivity glue and a protocol fix lands in one place instead of three.

You normally consume it through a framework binding. Use it directly when building a new binding, a non-React/Vue/Svelte integration, a server-side consumer, or tests.

Positioning. Everyone else ships “add AI chat” — a synchronous stream that dies with the page. ui-core exposes the layers underneath that decide whether an agent feature ships to production: resumable streams, durable human-in-the-loop, safe generative UI, and zero-token testing.

Terminal window
bun add @reactive-agents/ui-core
AreaExports
Wire protocolPROTOCOL_VERSION, UiStreamEvent, UiRunStatus, parseUiStreamEvent, isTerminalEvent, SeqStamped, PendingInteractionWire
Stream clientconnectRunStream, ConnectOptions, FetchLike
Run state machineinitialRunState, reduceRunState, RunState, ReduceOptions
Generative UIUiNode, isUiNode, uiTreeSchema, reconcileUiTree
InboxInboxRun, fetchInbox
Durable railsInteractionResult, respondToInteraction, decideApproval
Testing (/testing)RunFixture, recordRunFixture, fixtureToSSE, mockAgentEndpoint

A versioned, additive-only SSE event contract. Every event has a _tag; base tags mirror the server’s AgentStreamEvent, and this kit adds durable/observability tags (RunAttached, InteractionRequested, ApprovalRequested, RunPaused, CostDelta, Abstained, LimitExceeded, plus reserved-for-v2 UiTreeDelta/TrustEvent/StepEvent).

import { parseUiStreamEvent, isTerminalEvent } from "@reactive-agents/ui-core";
const event = parseUiStreamEvent('{"_tag":"TextDelta","text":"hi"}'); // typed UiStreamEvent | null
isTerminalEvent(event!); // false — true for StreamCompleted/Error/Cancelled/LimitExceeded

connectRunStream yields typed, sequence-stamped events; reduceRunState folds them into UI state. This pair is what every binding wraps.

import { connectRunStream, reduceRunState, initialRunState } from "@reactive-agents/ui-core";
let state = initialRunState();
for await (const event of connectRunStream({ endpoint: "/api/agent", body: { prompt: "Explain SSE" } })) {
state = reduceRunState(state, event);
// state.text grows token-by-token; state.status → "completed" | "awaiting-interaction" | "error" | …
}

RunState carries { status, runId, text, output, object, events, pendingInteraction, pendingApproval, abstention, cost, error, lastSeq }. Pass { objectMode: true } to reduceRunState to derive a partial object from streamed JSON via parsePartialObject.

Streams survive a page reload, tab close, or server restart. connectRunStream tracks the highest sequence number seen; in attach mode a mid-stream drop reconnects from cursor = lastSeq with exponential backoff up to maxRetries — no event lost or duplicated.

for await (const event of connectRunStream({
endpoint: "/api/agent",
attach: { runId: "run_123", cursor: state.lastSeq }, // GET reattach + durable replay
})) {
state = reduceRunState(state, event);
}

When an agent calls request_user_input or hits an approval gate (see Durable Human-in-the-Loop), the run pauses durably and the stream carries a pendingInteraction / pendingApproval. Answer it from the same page, after a reload, or from another device — the run resumes from its checkpoint.

import { respondToInteraction, decideApproval } from "@reactive-agents/ui-core";
await respondToInteraction({
endpoint: "/api/interaction",
runId: state.pendingInteraction!.runId,
interactionId: state.pendingInteraction!.interactionId,
value: { choice: "ship it" },
});
await decideApproval({
endpoint: "/api/approval",
runId: state.pendingApproval!.runId,
gateId: state.pendingApproval!.gateId,
decision: "approve", // or "deny" with an optional reason
});

Both return InteractionResult { success, output, error? } and never throw — a failed POST returns { success: false, error }, so bindings render honest error states without a try/catch.

uiTreeSchema(registry) builds a structured-output schema whose node type is an enum over your registry keys — a model can only emit component types you registered. Hallucinated components are unrepresentable, not merely rejected. No eval, no arbitrary markup.

import { uiTreeSchema, reconcileUiTree, type UiNode } from "@reactive-agents/ui-core";
const registry = { card: 1, table: 1, row: 1 };
const schema = uiTreeSchema(registry); // → .withOutputSchema(schema) server-side
let tree: UiNode | undefined;
tree = reconcileUiTree(tree, { type: "card", props: { title: "Sales" } });
tree = reconcileUiTree(tree, { type: "card", props: { body: "…streamed later" } });
// → { type: "card", props: { title: "Sales", body: "…streamed later" } }

reconcileUiTree merges partial → accumulated: partial fields win, props shallow-merge, children merge positionally + recursively, a non-node partial leaves the tree untouched. Progressive-append semantics (no child removal/reorder).

fetchInbox pulls the durable-run inbox for the resolved identity — agent jobs that run detached, email-like.

import { fetchInbox } from "@reactive-agents/ui-core";
const runs = await fetchInbox({ endpoint: "/api/inbox" }); // InboxRun[] — throws on non-ok

Record a real run’s event stream once, then replay the exact SSE bytes for any request — no provider, no network, no flake — in Vitest/Playwright/Storybook.

import { recordRunFixture, mockAgentEndpoint } from "@reactive-agents/ui-core/testing";
const fixture = await recordRunFixture(agentStream); // capture once
const fetchImpl = mockAgentEndpoint(fixture); // replay everywhere
for await (const e of connectRunStream({ endpoint: "/api/agent", body: { prompt: "hi" }, fetchImpl })) { /* … */ }

FetchLike — the (input, init?) => Promise<Response> seam used across connectRunStream, fetchInbox, respondToInteraction, and friends — is what makes this injection cast-free (the global fetch satisfies it).

Dependency direction: ui-core depends on nothing; the framework bindings depend on ui-core; never the reverse. See the Web Integration guide to wire it into a Next.js / SvelteKit / Nuxt app, and Durable Human-in-the-Loop for the server-side pause/resume rails.