diff --git a/.changeset/final-only-flight-dispatcher.md b/.changeset/final-only-flight-dispatcher.md
new file mode 100644
index 000000000..5ca139d1e
--- /dev/null
+++ b/.changeset/final-only-flight-dispatcher.md
@@ -0,0 +1,8 @@
+---
+"@agent-bundle/runtime": minor
+---
+
+Add React-owned final-only Flight execution behind the public render-dispatcher
+and execution-host seam. Decode intrinsic `Agent.*` output into one immutable
+Agent Document and propagate request cancellation without changing the existing
+synchronous MCP lowerer path.
diff --git a/docs/framework-mode.md b/docs/framework-mode.md
index 5bebb95d1..054a4ee71 100644
--- a/docs/framework-mode.md
+++ b/docs/framework-mode.md
@@ -124,16 +124,19 @@ validation cannot drift between surfaces. Only the last step differs:
## Why `.tsx`, and current renderer status
-`@agent-bundle/runtime` does **not yet execute React Server Components or
-Flight**. Its current compatibility lowerers do not stream a component tree,
-hydrate, or hold server component state. What the MCP projection uses is an **MCP
-result DSL**: `render` returns ordinary React elements, and
-`lowerMcpResult` walks that tree synchronously — function components are
-simply called — to produce the `CallToolResult` the MCP SDK sends. The
-package owns no transport, and operations receive no implicit storage:
-persistent application state exists only through the opt-in state kernel
-subpath (`@agent-bundle/runtime/state`, issue #98), which stateless projects
-never import.
+The operation model shown above still uses the **synchronous MCP result DSL**:
+`render` returns ordinary React elements and `lowerMcpResult` walks that tree
+to produce the `CallToolResult` the MCP SDK sends. That compatibility path does
+not involve Flight and remains the operative MCP projection.
+
+Separately, `@agent-bundle/runtime` now exposes a final-only React-owned Flight
+dispatcher for generated routes. An execution host supplies Flight bytes, the
+dispatcher decodes intrinsic `Agent.*` elements into one immutable
+`AgentDocument`, and cancellation follows the request `AbortSignal`. Streaming
+Suspense replacement and public filesystem-route authoring are later stages.
+Operations receive no implicit storage: persistent application state exists
+only through the opt-in `@agent-bundle/runtime/state` kernel, which stateless
+projects never import.
Operation modules are `.tsx` for exactly one reason: the `render` callback
returns JSX. Everything else in an operation — schemas, argv parsing, MCP
@@ -150,9 +153,9 @@ For a new reader, in one breath:
around them).
3. **Which projection consumes `render`?** Only MCP, though every operation
must declare one. The CLI serializes the validated result as JSON.
-4. **Is any React Server Components renderer or Flight transport
- involved?** No. `lowerMcpResult` is a synchronous element-tree lowering,
- not a renderer or transport.
+4. **Is Flight involved in this operation projection?** No.
+ `lowerMcpResult` remains synchronous. The separate generated-route path uses
+ the final-only `AgentRenderDispatcher` described above.
5. **Why are operation modules `.tsx`?** Only because `render` returns JSX.
## Rendered skills (power tier, never required)
diff --git a/examples/rsc-agent-runtime/README.md b/examples/rsc-agent-runtime/README.md
index cb31748f8..4efebbd04 100644
--- a/examples/rsc-agent-runtime/README.md
+++ b/examples/rsc-agent-runtime/README.md
@@ -11,11 +11,11 @@ This private, opt-in example shows one React Server Components (RSC) runtime sha
| RSC render | Hook and MCP result component trees, lowered from Flight | One request |
| MCP App UI | Mounted timeline, Refresh, and recoverable row selection | One UI instance |
-Native hooks are fresh requests: a process normalizes one host event, invokes the RSC worker, lowers the Flight result, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls.
+Native hooks are fresh requests: a process normalizes one host event, invokes the RSC worker through the runtime dispatcher seam, projects the final Agent Document, and exits. The durable kernel—not a Node module cache or React state—connects later hook processes and MCP calls.
```tsx
-// A Hook JSX route reads request-scoped context.
-import { Hook, agent } from '@agent-bundle/runtime';
+// An Agent Document route reads request-scoped context.
+import { Agent, agent } from '@agent-bundle/runtime';
import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js';
export async function AfterFileEdit() {
@@ -23,11 +23,11 @@ export async function AfterFileEdit() {
const edit = context.services.edit as CanonicalPostToolUse;
const snapshot = context.services.snapshot as RuntimeSnapshot;
return (
-
-
+
+
{`Recorded ${edit.path}; ${snapshot.edits.length} edits exist.`}
-
-
+
+
);
}
```
diff --git a/examples/rsc-agent-runtime/src/dev/invocation-worker.ts b/examples/rsc-agent-runtime/src/dev/invocation-worker.ts
index 062bdbe88..4e9a6e609 100644
--- a/examples/rsc-agent-runtime/src/dev/invocation-worker.ts
+++ b/examples/rsc-agent-runtime/src/dev/invocation-worker.ts
@@ -1,6 +1,6 @@
-import { requestFlightRenderWithFlight } from '../flight/request-render.js';
+import { requestAgentDocumentWithFlight, requestFlightRenderWithFlight } from '../flight/request-render.js';
import { writeSync } from 'node:fs';
-import { lowerHookResult, lowerMcpResult } from '@agent-bundle/runtime';
+import { lowerMcpResult } from '@agent-bundle/runtime';
import type {
DevRuntimeInspectionRequest,
DevRuntimeInspectionResponse,
@@ -9,6 +9,7 @@ import type {
RuntimeSnapshot,
} from '../runtime/contracts.js';
import { normalizeClaudeHook, normalizeCodexHook } from '../hook/normalize.js';
+import { projectHookDocument } from '../hook/project-document.js';
import { hasInspectionCredential, isInspectionSensitiveKey } from './inspection-security.js';
import { serializeInspection } from './serialize-inspection.js';
@@ -163,41 +164,46 @@ interface InvocationOutput {
const invoke = async (signal?: AbortSignal): Promise => {
const request = await readRequest();
- const rendered = await requestFlightRenderWithFlight(renderRequestFor(request), {
- maximumFlightBytes: maximumInvocationFlightBytes,
- signal,
- });
+ const renderRequest = renderRequestFor(request);
if (request.type === 'hook/after-file-edit') {
- const native = lowerHookResult(rendered.node);
+ const rendered = await requestAgentDocumentWithFlight(renderRequest, {
+ maximumFlightBytes: maximumInvocationFlightBytes,
+ signal,
+ });
+ const native = projectHookDocument(rendered.document);
return Object.freeze({
flight: Buffer.from(rendered.flight),
response: Object.freeze({
flightBytes: rendered.flight.byteLength,
inspection: serializeInspection({
- agentVisible: native.hookSpecificOutput.additionalContext,
- flight: rendered.flight,
- native,
- node: rendered.node,
- stateStoreId: request.stateStoreId,
- stateVersion: rendered.stateVersion,
+ agentVisible: native.hookSpecificOutput.additionalContext,
+ flight: rendered.flight,
+ native,
+ node: rendered.node,
+ stateStoreId: request.stateStoreId,
+ stateVersion: rendered.stateVersion,
}),
}),
});
}
+ const rendered = await requestFlightRenderWithFlight(renderRequest, {
+ maximumFlightBytes: maximumInvocationFlightBytes,
+ signal,
+ });
const protocol = lowerMcpResult(rendered.node);
return Object.freeze({
flight: Buffer.from(rendered.flight),
response: Object.freeze({
flightBytes: rendered.flight.byteLength,
inspection: serializeInspection({
- flight: rendered.flight,
- modelVisible: protocol.content,
- node: rendered.node,
- protocol,
- stateStoreId: request.stateStoreId,
- stateVersion: rendered.stateVersion,
+ flight: rendered.flight,
+ modelVisible: protocol.content,
+ node: rendered.node,
+ protocol,
+ stateStoreId: request.stateStoreId,
+ stateVersion: rendered.stateVersion,
}),
}),
});
diff --git a/examples/rsc-agent-runtime/src/flight/request-render.ts b/examples/rsc-agent-runtime/src/flight/request-render.ts
index 351a8a700..6ce375acf 100644
--- a/examples/rsc-agent-runtime/src/flight/request-render.ts
+++ b/examples/rsc-agent-runtime/src/flight/request-render.ts
@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url';
import { createFromReadableStream } from 'react-server-dom-rspack/client.node';
import type { ReactNode } from 'react';
+import { createAgentRenderDispatcher, type AgentDocument, type AgentRenderInvocation } from '@agent-bundle/runtime';
+
import type { RenderRequest } from '../runtime/contracts.js';
import { redactInspectionDiagnostics } from '../dev/inspection-security.js';
@@ -22,6 +24,10 @@ export interface FlightRenderResult {
readonly stateVersion: number;
}
+export interface AgentDocumentFlightRenderResult extends FlightRenderResult {
+ readonly document: AgentDocument;
+}
+
export interface FlightRenderOptions {
readonly maximumFlightBytes?: number;
readonly maximumStderrBytes?: number;
@@ -188,3 +194,62 @@ export const requestFlightRenderWithFlight = async (
export const requestFlightRender = async (request: RenderRequest): Promise =>
(await requestFlightRenderWithFlight(request)).node;
+
+const renderInvocationFor = (request: RenderRequest): AgentRenderInvocation => {
+ switch (request.type) {
+ case 'hook/after-file-edit':
+ return {
+ kind: 'event',
+ props: {
+ event: request.type,
+ payload: { event: { ...request.event }, stateFile: request.stateFile },
+ },
+ };
+ case 'mcp/render-timeline':
+ return {
+ kind: 'tool',
+ props: {
+ input: {
+ snapshot: {
+ edits: request.snapshot.edits.map((edit) => ({ ...edit })),
+ ...(request.snapshot.seed === undefined ? {} : { seed: request.snapshot.seed }),
+ stateVersion: request.snapshot.stateVersion,
+ },
+ stateFile: request.stateFile,
+ },
+ operationId: request.type,
+ },
+ };
+ case 'mcp/runtime-status':
+ return {
+ kind: 'tool',
+ props: { input: { stateFile: request.stateFile }, operationId: request.type },
+ };
+ default: {
+ const exhaustive: never = request;
+ return exhaustive;
+ }
+ }
+};
+
+export const requestAgentDocumentWithFlight = async (
+ request: RenderRequest,
+ options: FlightRenderOptions = {},
+): Promise => {
+ let rendered: FlightRenderResult | undefined;
+ const signal = options.signal ?? new AbortController().signal;
+ const dispatcher = createAgentRenderDispatcher({
+ execute: async (dispatch) => {
+ rendered = await requestFlightRenderWithFlight(request, { ...options, signal: dispatch.signal });
+ return Readable.toWeb(Readable.from([rendered.flight])) as ReadableStream;
+ },
+ });
+ const document = await dispatcher.dispatch({ invocation: renderInvocationFor(request), signal });
+ if (rendered === undefined) throw new Error('Flight execution host returned no render result');
+ return Object.freeze({ ...rendered, document });
+};
+
+export const requestAgentDocument = async (
+ request: RenderRequest,
+ options: FlightRenderOptions = {},
+): Promise => (await requestAgentDocumentWithFlight(request, options)).document;
diff --git a/examples/rsc-agent-runtime/src/hook/cli.ts b/examples/rsc-agent-runtime/src/hook/cli.ts
index e5ed376de..c7e93dcb6 100644
--- a/examples/rsc-agent-runtime/src/hook/cli.ts
+++ b/examples/rsc-agent-runtime/src/hook/cli.ts
@@ -1,10 +1,10 @@
import { appendFile } from 'node:fs/promises';
import { resolve } from 'node:path';
-import { requestFlightRender } from '../flight/request-render.js';
-import { lowerHookResult } from '@agent-bundle/runtime';
+import { requestAgentDocument } from '../flight/request-render.js';
import { resolveImplicitRuntimeStateFile } from '../runtime/state-file.js';
import { normalizeClaudeHook, normalizeCodexHook } from './normalize.js';
+import { projectHookDocument } from './project-document.js';
let probeInput: Record | undefined;
@@ -59,7 +59,7 @@ const readHost = (): 'claude' | 'codex' => {
return host;
};
-const run = async (): Promise => {
+const run = async (signal: AbortSignal): Promise => {
const host = readHost();
const input = await readInput();
probeInput = input;
@@ -69,18 +69,26 @@ const run = async (): Promise => {
? await resolveImplicitRuntimeStateFile(event.cwd)
: resolve(configuredStateFile);
- const result = await requestFlightRender({
+ const document = await requestAgentDocument({
event,
stateFile,
type: 'hook/after-file-edit',
- });
- process.stdout.write(`${JSON.stringify(lowerHookResult(result))}\n`);
+ }, { signal });
+ process.stdout.write(`${JSON.stringify(projectHookDocument(document))}\n`);
await writeEvalProbe(input, 0);
};
-run().catch(async (error: unknown) => {
+const controller = new AbortController();
+const abort = (): void => controller.abort();
+process.once('SIGINT', abort);
+process.once('SIGTERM', abort);
+
+run(controller.signal).catch(async (error: unknown) => {
if (probeInput !== undefined) await writeEvalProbe(probeInput, 1).catch(() => undefined);
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
+}).finally(() => {
+ process.removeListener('SIGINT', abort);
+ process.removeListener('SIGTERM', abort);
});
diff --git a/examples/rsc-agent-runtime/src/hook/project-document.ts b/examples/rsc-agent-runtime/src/hook/project-document.ts
new file mode 100644
index 000000000..3bd774a65
--- /dev/null
+++ b/examples/rsc-agent-runtime/src/hook/project-document.ts
@@ -0,0 +1,16 @@
+import type { AgentDocument, NativePostToolUseOutput } from '@agent-bundle/runtime';
+
+export const projectHookDocument = (document: AgentDocument): NativePostToolUseOutput => {
+ if (document.status === 'failed' || document.root.kind !== 'result') {
+ throw new Error('Hook render requires a successful Agent.Result document');
+ }
+ if (document.root.children.length !== 1 || document.root.children[0]?.kind !== 'text') {
+ throw new Error('Hook render requires exactly one Agent.Text child');
+ }
+ return {
+ hookSpecificOutput: {
+ additionalContext: document.root.children[0].text,
+ hookEventName: 'PostToolUse',
+ },
+ };
+};
diff --git a/examples/rsc-agent-runtime/src/rsc/components.tsx b/examples/rsc-agent-runtime/src/rsc/components.tsx
index 5bda68749..b849efeb0 100644
--- a/examples/rsc-agent-runtime/src/rsc/components.tsx
+++ b/examples/rsc-agent-runtime/src/rsc/components.tsx
@@ -1,6 +1,6 @@
import { basename } from 'node:path';
-import { Hook, Mcp, agent } from '@agent-bundle/runtime';
+import { Agent, Mcp, agent } from '@agent-bundle/runtime';
import type { CanonicalPostToolUse, RuntimeSnapshot } from '../runtime/contracts.js';
const hookServices = async (): Promise<{ edit: CanonicalPostToolUse; snapshot: RuntimeSnapshot }> => {
@@ -22,11 +22,11 @@ export const AfterFileEdit = async () => {
const editNoun = editCount === 1 ? 'edit' : 'edits';
return (
-
-
+
+
{`Recorded ${basename(edit.path)} from ${edit.host}. Shared state now contains ${editCount} ${editNoun}.`}
-
-
+
+
);
};
diff --git a/examples/rsc-agent-runtime/src/rsc/worker.tsx b/examples/rsc-agent-runtime/src/rsc/worker.tsx
index 95c5388cc..7d1dc9406 100644
--- a/examples/rsc-agent-runtime/src/rsc/worker.tsx
+++ b/examples/rsc-agent-runtime/src/rsc/worker.tsx
@@ -4,7 +4,7 @@ import { resolve } from 'node:path';
import { writeSync } from 'node:fs';
import { available, runAgentRequest } from '@agent-bundle/runtime';
-import { renderToReadableStream } from 'react-server-dom-rspack/server.node';
+import { renderAgentFlight } from '@agent-bundle/runtime/flight/server';
import type { CanonicalPostToolUse, RenderRequest, RuntimeSnapshot } from '../runtime/contracts.js';
import { createFileRuntimeKernel } from '../runtime/state-file.js';
@@ -103,7 +103,7 @@ const readRequest = async (): Promise => {
return parseRequest(JSON.parse(contents));
};
-const render = async (): Promise => {
+const render = async (signal: AbortSignal): Promise => {
const request = await readRequest();
const runtime = createFileRuntimeKernel({ stateFile: request.stateFile });
const snapshot =
@@ -120,7 +120,7 @@ const render = async (): Promise => {
: await runtime.readSnapshot();
const renderFlight = async (): Promise => {
- const flight = renderToReadableStream(renderRoute(request, snapshot));
+ const flight = renderAgentFlight(renderRoute(request, snapshot), { signal });
const output = Readable.from(flight);
output.pipe(process.stdout, { end: false });
await finished(output);
@@ -145,6 +145,7 @@ const render = async (): Promise => {
session: available({ sessionId: request.event.sessionId }, 'native'),
services: { edit: request.event, snapshot },
workspace: available({ root: request.event.cwd }, 'native'),
+ signal,
}, renderFlight);
} else {
await runAgentRequest({
@@ -153,13 +154,22 @@ const render = async (): Promise => {
surface: request.type,
},
services: { snapshot },
+ signal,
}, renderFlight);
}
writeSnapshotMetadata();
};
-render().catch((error: unknown) => {
+const controller = new AbortController();
+const abort = (): void => controller.abort();
+process.once('SIGINT', abort);
+process.once('SIGTERM', abort);
+
+render(controller.signal).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
+}).finally(() => {
+ process.removeListener('SIGINT', abort);
+ process.removeListener('SIGTERM', abort);
});
diff --git a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts
index eba942718..009349b7b 100644
--- a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts
+++ b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts
@@ -310,12 +310,12 @@ test('builds a generation-contained inspection entry for Claude, Codex, and MCP
],
id: 'node-1',
kind: 'element',
- label: 'agent-hook-additional-context',
+ label: 'agent-text',
},
],
id: 'node-0',
kind: 'element',
- label: 'agent-hook-result',
+ label: 'agent-result',
},
],
});
diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md
index b516ed87d..e6f1ee47f 100644
--- a/packages/rsc-runtime/README.md
+++ b/packages/rsc-runtime/README.md
@@ -1,18 +1,20 @@
# `@agent-bundle/runtime`
-Small React primitives for producing Agent Bundle hook and MCP protocol results with JSX.
+Agent Document contracts and React-owned Flight execution for Agent Bundle routes.
No npm release is cut yet; install the pkg.pr.new preview of any `main` commit or pull
request — see [Preview packages](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/preview-packages.md).
-The runtime package does **not yet execute React Server Components or
-Flight**. Its current lowerers form a synchronous React-element protocol DSL —
-an *MCP result DSL*: `lowerMcpResult` walks an
-element tree, calling your function components as it goes, and lowers it
-into a plain MCP `CallToolResult`. `lowerHookResult` lowers a `Hook.Result`
-tree into a native `PostToolUse` output the same way, except that it
-resolves only the `Hook` elements themselves — a hook tree returned from
-your own component is rejected. Nothing streams components, hydrates, or
-holds server component state.
+The runtime now executes route models through React-owned RSC/Flight behind
+the `AgentRenderDispatcher` execution-host seam. This first renderer slice is
+final-only: it buffers one Flight result, decodes only intrinsic `Agent.*`
+protocol elements into one immutable `AgentDocument`, and propagates the
+request `AbortSignal` through the host and decoder. Streaming Suspense shell
+and replacement events arrive in stage 3.
+
+The existing lowerers remain synchronous compatibility APIs. `lowerMcpResult`
+walks an MCP element tree, calling function components itself, and lowers it
+into a plain `CallToolResult`; `lowerHookResult` does the same for `Hook.*`.
+They remain the operative MCP path until the later projector migration.
```tsx
import { Mcp, lowerMcpResult } from '@agent-bundle/runtime';
@@ -34,9 +36,13 @@ error. These contracts land beside the existing `Hook`/`Mcp` lowerers; those
synchronous compatibility APIs remain operative.
The package exports `Hook`, `Mcp`, `Agent`, both lowerers, the request-store
-APIs, and the Agent Document contracts. It does not yet own application state,
-transport, persistence, or host packaging. React 19 is a peer dependency and
-Node 22.19 or newer is required.
+APIs, the Agent Document contracts, `createAgentRenderDispatcher`, and the
+`@agent-bundle/runtime/flight/server` render entry. The Flight-facing versions
+are exact compatibility pins: React/React DOM `19.2.8` and
+`react-server-dom-rspack` `0.1.0`; the proof example compiles them with
+`rsbuild-plugin-rsc` `0.1.1`. The package does not own application state,
+persistence, a concrete execution host, or host packaging. Node 22.19 or newer
+is required.
Async server utilities and Server Components read the framework request store
with `const context = await agent()`. The store is a versioned realm singleton
diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json
index 17b190bd2..2305531ee 100644
--- a/packages/rsc-runtime/package.json
+++ b/packages/rsc-runtime/package.json
@@ -1,7 +1,7 @@
{
"name": "@agent-bundle/runtime",
"version": "0.0.0",
- "description": "Versioned Agent Document contracts and React protocol primitives for Agent Bundle runtimes.",
+ "description": "Agent Document contracts and final-only React Flight dispatch for Agent Bundle runtimes.",
"license": "MIT",
"keywords": [
"agent-bundle",
@@ -37,6 +37,10 @@
"types": "./dist/plugin.d.ts",
"import": "./dist/plugin.js"
},
+ "./flight/server": {
+ "types": "./dist/flight/server.d.ts",
+ "import": "./dist/flight/server.js"
+ },
"./state": {
"types": "./dist/state/index.d.ts",
"import": "./dist/state.js"
@@ -48,18 +52,23 @@
"typecheck": "tsc -p tsconfig.build.json --noEmit"
},
"peerDependencies": {
- "react": "^19.2.0"
+ "@rspack/core": "^2.2.0-0",
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
"@modelcontextprotocol/server": "2.0.0",
+ "react-server-dom-rspack": "0.1.0",
"zod": "4.4.3"
},
"devDependencies": {
"@modelcontextprotocol/client": "2.0.0",
"@rslib/core": "0.23.2",
+ "@rspack/core": "2.2.1",
"@rstest/core": "0.11.10",
"@types/react": "19.2.18",
- "react": "19.2.8"
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
}
}
diff --git a/packages/rsc-runtime/rslib.config.ts b/packages/rsc-runtime/rslib.config.ts
index 398ce74af..7dad0a79d 100644
--- a/packages/rsc-runtime/rslib.config.ts
+++ b/packages/rsc-runtime/rslib.config.ts
@@ -17,6 +17,7 @@ export default defineConfig({
root: import.meta.dirname,
source: {
entry: {
+ 'flight/server': './src/flight/server.ts',
index: './src/index.ts',
plugin: './src/plugin.ts',
state: './src/state/index.ts',
diff --git a/packages/rsc-runtime/src/dispatcher.ts b/packages/rsc-runtime/src/dispatcher.ts
new file mode 100644
index 000000000..7f42e0dfc
--- /dev/null
+++ b/packages/rsc-runtime/src/dispatcher.ts
@@ -0,0 +1,172 @@
+import { Children, isValidElement, type ReactNode } from 'react';
+import { createFromReadableStream } from 'react-server-dom-rspack/client.node';
+
+import {
+ AgentContractError,
+ createAgentDocument,
+ type AgentDocument,
+ type AgentDocumentNode,
+ type AgentRenderLimits,
+} from './agent-document.js';
+import type { AgentRenderInvocation } from './agent-request.js';
+import type { JsonValue } from './lower-mcp.js';
+
+export interface AgentRenderDispatch {
+ readonly invocation: AgentRenderInvocation;
+ readonly signal: AbortSignal;
+}
+
+export interface AgentFlightExecutionHost {
+ readonly execute: (request: AgentRenderDispatch) => Promise>;
+}
+
+export interface AgentRenderDispatcher {
+ readonly dispatch: (request: AgentRenderDispatch) => Promise;
+}
+
+export interface AgentRenderDispatcherOptions {
+ readonly limits?: Partial;
+}
+
+const agentElementTypes = Object.freeze([
+ 'agent-result',
+ 'agent-markdown',
+ 'agent-text',
+ 'agent-json',
+ 'agent-progress',
+ 'agent-image',
+ 'agent-audio',
+ 'agent-resource',
+ 'agent-error',
+] as const);
+
+type AgentElementType = typeof agentElementTypes[number];
+
+interface AgentProtocolElement {
+ readonly props: Record;
+ readonly type: AgentElementType;
+}
+
+const isAgentElementType = (value: string): value is AgentElementType =>
+ (agentElementTypes as readonly string[]).includes(value);
+
+const protocolElement = (node: ReactNode): AgentProtocolElement => {
+ if (
+ !isValidElement(node) ||
+ typeof node.type !== 'string' ||
+ !isAgentElementType(node.type)
+ ) {
+ throw new AgentContractError(
+ 'invalid-document',
+ 'Flight output must contain only Agent protocol elements; function components and HTML are unsupported',
+ );
+ }
+ return { props: node.props as Record, type: node.type };
+};
+
+const textChild = (children: unknown, type: AgentElementType): string => {
+ const values = Children.toArray(children as ReactNode);
+ if (values.length !== 1 || typeof values[0] !== 'string') {
+ throw new AgentContractError('invalid-document', `${type} requires exactly one string child`);
+ }
+ return values[0];
+};
+
+interface DecodeState {
+ representedError: boolean;
+}
+
+const decodeNode = (node: ReactNode, state: DecodeState): AgentDocumentNode => {
+ const element = protocolElement(node);
+ const { props } = element;
+ switch (element.type) {
+ case 'agent-result':
+ return {
+ children: Children.toArray(props.children as ReactNode).map((child) => decodeNode(child, state)),
+ kind: 'result',
+ ...(props.metadata === undefined ? {} : { metadata: props.metadata as JsonValue }),
+ };
+ case 'agent-markdown':
+ return { kind: 'markdown', text: textChild(props.children, element.type) };
+ case 'agent-text':
+ return { kind: 'text', text: textChild(props.children, element.type) };
+ case 'agent-json':
+ return { kind: 'json', value: props.value as JsonValue };
+ case 'agent-progress':
+ return {
+ completed: props.completed as number,
+ kind: 'progress',
+ ...(props.message === undefined ? {} : { message: props.message as string }),
+ ...(props.total === undefined ? {} : { total: props.total as number }),
+ };
+ case 'agent-image':
+ return { data: props.data as string, kind: 'image', mimeType: props.mimeType as string };
+ case 'agent-audio':
+ return { data: props.data as string, kind: 'audio', mimeType: props.mimeType as string };
+ case 'agent-resource':
+ return {
+ kind: 'resource',
+ ...(props.mimeType === undefined ? {} : { mimeType: props.mimeType as string }),
+ name: props.name as string,
+ uri: props.uri as string,
+ };
+ case 'agent-error':
+ state.representedError = true;
+ return {
+ code: props.code as string,
+ kind: 'error',
+ message: textChild(props.children, element.type),
+ };
+ default: {
+ const exhaustive: never = element.type;
+ throw new AgentContractError('invalid-document', `Unsupported Agent protocol element: ${String(exhaustive)}`);
+ }
+ }
+};
+
+export const decodeAgentDocument = (
+ node: ReactNode,
+ limits: Partial = {},
+): AgentDocument => {
+ const root = protocolElement(node);
+ if (root.type !== 'agent-result') {
+ throw new AgentContractError('invalid-document', 'Flight output must have Agent.Result as its root');
+ }
+ const state: DecodeState = { representedError: false };
+ const documentRoot = decodeNode(node, state);
+ return createAgentDocument({
+ root: documentRoot,
+ status: state.representedError ? 'represented-error' : 'success',
+ ...(root.props.value === undefined ? {} : { value: root.props.value as JsonValue }),
+ version: 1,
+ }, limits);
+};
+
+const abortError = (): DOMException => new DOMException('Agent render was aborted', 'AbortError');
+
+const withAbortSignal = (
+ stream: ReadableStream,
+ signal: AbortSignal,
+): ReadableStream => {
+ if (signal.aborted) throw abortError();
+ return stream.pipeThrough(new TransformStream(), { signal });
+};
+
+export const createAgentRenderDispatcher = (
+ host: AgentFlightExecutionHost,
+ options: AgentRenderDispatcherOptions = {},
+): AgentRenderDispatcher => Object.freeze({
+ async dispatch(request: AgentRenderDispatch): Promise {
+ if (request.signal.aborted) throw abortError();
+ try {
+ const flight = await host.execute(request);
+ if (request.signal.aborted) throw abortError();
+ const node = await createFromReadableStream(withAbortSignal(flight, request.signal));
+ if (request.signal.aborted) throw abortError();
+ return decodeAgentDocument(node, options.limits);
+ } catch (error) {
+ if (request.signal.aborted) throw abortError();
+ throw error;
+ }
+ },
+});
diff --git a/packages/rsc-runtime/src/elements.ts b/packages/rsc-runtime/src/elements.ts
index 066555180..c829ebbfb 100644
--- a/packages/rsc-runtime/src/elements.ts
+++ b/packages/rsc-runtime/src/elements.ts
@@ -4,6 +4,7 @@ import type { JsonValue } from './lower-mcp.js';
export interface AgentResultProps extends PropsWithChildren {
readonly metadata?: JsonValue;
+ readonly value?: JsonValue;
}
export interface AgentTextProps {
@@ -35,8 +36,8 @@ export interface AgentErrorProps extends AgentTextProps {
readonly code: string;
}
-const AgentResult = ({ children, metadata }: AgentResultProps): ReactElement =>
- createElement('agent-result', { metadata }, children);
+const AgentResult = ({ children, metadata, value }: AgentResultProps): ReactElement =>
+ createElement('agent-result', { metadata, value }, children);
const AgentMarkdown = ({ children }: AgentTextProps): ReactElement =>
createElement('agent-markdown', null, children);
diff --git a/packages/rsc-runtime/src/flight/server.ts b/packages/rsc-runtime/src/flight/server.ts
new file mode 100644
index 000000000..96a220c2b
--- /dev/null
+++ b/packages/rsc-runtime/src/flight/server.ts
@@ -0,0 +1,21 @@
+import type { ReactNode } from 'react';
+import { renderToReadableStream } from 'react-server-dom-rspack/server.node';
+
+export interface AgentFlightRenderOptions {
+ readonly onError?: (error: unknown) => string | undefined;
+ readonly signal?: AbortSignal;
+}
+
+const abortError = (): DOMException => new DOMException('Agent render was aborted', 'AbortError');
+
+export const renderAgentFlight = (
+ model: ReactNode,
+ options: AgentFlightRenderOptions = {},
+): ReadableStream => {
+ if (options.signal?.aborted) throw abortError();
+ const flight = renderToReadableStream(model, {
+ ...(options.onError === undefined ? {} : { onError: options.onError }),
+ });
+ if (options.signal === undefined) return flight;
+ return flight.pipeThrough(new TransformStream(), { signal: options.signal });
+};
diff --git a/packages/rsc-runtime/src/index.ts b/packages/rsc-runtime/src/index.ts
index 08c077a60..c92c06b68 100644
--- a/packages/rsc-runtime/src/index.ts
+++ b/packages/rsc-runtime/src/index.ts
@@ -40,6 +40,13 @@ export type {
AgentResultNode,
AgentTextNode,
} from './agent-document.js';
+export { createAgentRenderDispatcher, decodeAgentDocument } from './dispatcher.js';
+export type {
+ AgentFlightExecutionHost,
+ AgentRenderDispatch,
+ AgentRenderDispatcher,
+ AgentRenderDispatcherOptions,
+} from './dispatcher.js';
export { lowerHookResult } from './lower-hook.js';
export type { NativePostToolUseOutput } from './lower-hook.js';
export { lowerMcpResult } from './lower-mcp.js';
diff --git a/packages/rsc-runtime/src/react-server-dom-rspack.d.ts b/packages/rsc-runtime/src/react-server-dom-rspack.d.ts
new file mode 100644
index 000000000..fd434a090
--- /dev/null
+++ b/packages/rsc-runtime/src/react-server-dom-rspack.d.ts
@@ -0,0 +1,16 @@
+declare module 'react-server-dom-rspack/client.node' {
+ export function createFromReadableStream(
+ stream: ReadableStream,
+ options?: Readonly<{ temporaryReferences?: unknown }>,
+ ): Promise;
+}
+
+declare module 'react-server-dom-rspack/server.node' {
+ export function renderToReadableStream(
+ model: unknown,
+ options?: Readonly<{
+ onError?: (error: unknown) => string | undefined;
+ temporaryReferences?: unknown;
+ }>,
+ ): ReadableStream;
+}
diff --git a/packages/rsc-runtime/tests/dispatcher.test.ts b/packages/rsc-runtime/tests/dispatcher.test.ts
new file mode 100644
index 000000000..773aac75c
--- /dev/null
+++ b/packages/rsc-runtime/tests/dispatcher.test.ts
@@ -0,0 +1,88 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { describe, expect, it } from '@rstest/core';
+import { createElement } from 'react';
+
+import {
+ createAgentRenderDispatcher,
+ decodeAgentDocument,
+ type AgentFlightExecutionHost,
+} from '../src/index.js';
+
+describe('decodeAgentDocument', () => {
+ it('decodes protocol host elements into one immutable final document', () => {
+ const document = decodeAgentDocument(createElement(
+ 'agent-result',
+ { metadata: { source: 'flight' }, value: { ready: true } },
+ createElement('agent-markdown', null, '# Ready'),
+ createElement('agent-error', { code: 'E_REPRESENTED' }, 'Partial result'),
+ ));
+
+ expect(document).toEqual({
+ root: {
+ children: [
+ { kind: 'markdown', text: '# Ready' },
+ { code: 'E_REPRESENTED', kind: 'error', message: 'Partial result' },
+ ],
+ kind: 'result',
+ metadata: { source: 'flight' },
+ },
+ status: 'represented-error',
+ value: { ready: true },
+ version: 1,
+ });
+ expect(Object.isFrozen(document)).toBe(true);
+ });
+
+ it('never invokes function components while decoding Flight output', () => {
+ let invoked = false;
+ const Component = () => {
+ invoked = true;
+ return createElement('agent-result');
+ };
+
+ expect(() => decodeAgentDocument(createElement(Component))).toThrow('protocol element');
+ expect(invoked).toBe(false);
+ expect(() => decodeAgentDocument(createElement('div'))).toThrow('protocol element');
+ });
+});
+
+describe('AgentRenderDispatcher', () => {
+ it('passes the request AbortSignal to the execution host and fails before execution when already aborted', async () => {
+ let calls = 0;
+ const host: AgentFlightExecutionHost = {
+ execute: async () => {
+ calls += 1;
+ return new ReadableStream();
+ },
+ };
+ const dispatcher = createAgentRenderDispatcher(host);
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(dispatcher.dispatch({
+ invocation: { kind: 'event', props: { event: 'tool/after', payload: {} } },
+ signal: controller.signal,
+ })).rejects.toMatchObject({ name: 'AbortError' });
+ expect(calls).toBe(0);
+ });
+});
+
+
+describe('Flight compatibility pins', () => {
+ it('keeps the runtime and proof compiler on the exact proven package set', () => {
+ const runtime = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')) as {
+ dependencies: Record;
+ peerDependencies: Record;
+ };
+ const example = JSON.parse(readFileSync(join(import.meta.dirname, '..', '..', '..', 'examples/rsc-agent-runtime/package.json'), 'utf8')) as {
+ devDependencies: Record;
+ };
+
+ expect(runtime.dependencies['react-server-dom-rspack']).toBe('0.1.0');
+ expect(runtime.peerDependencies.react).toBe('19.2.8');
+ expect(runtime.peerDependencies['react-dom']).toBe('19.2.8');
+ expect(example.devDependencies['rsbuild-plugin-rsc']).toBe('0.1.1');
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3c15d124d..2a4639a97 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -281,6 +281,9 @@ importers:
'@modelcontextprotocol/server':
specifier: 2.0.0
version: 2.0.0
+ react-server-dom-rspack:
+ specifier: 0.1.0
+ version: 0.1.0(@rspack/core@2.2.1(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
zod:
specifier: 4.4.3
version: 4.4.3
@@ -291,6 +294,9 @@ importers:
'@rslib/core':
specifier: 0.23.2
version: 0.23.2(typescript@7.0.2)
+ '@rspack/core':
+ specifier: 2.2.1
+ version: 2.2.1(@swc/helpers@0.5.23)
'@rstest/core':
specifier: 0.11.10
version: 0.11.10
@@ -300,6 +306,9 @@ importers:
react:
specifier: 19.2.8
version: 19.2.8
+ react-dom:
+ specifier: 19.2.8
+ version: 19.2.8(react@19.2.8)
packages/workbench:
dependencies: