diff --git a/.changeset/clarify-rsc-runtime-not-rsc.md b/.changeset/clarify-rsc-runtime-not-rsc.md
new file mode 100644
index 000000000..68e1bb6ef
--- /dev/null
+++ b/.changeset/clarify-rsc-runtime-not-rsc.md
@@ -0,0 +1,15 @@
+---
+"@agent-bundle/rsc-runtime": patch
+---
+
+Docs-only clarification of the operation/JSX model (#88). The published
+README now states explicitly that the package is **not** a React Server
+Components renderer or runtime and that no Flight transport is involved: it
+is a synchronous React-element protocol DSL (an "MCP result DSL") whose
+`lowerMcpResult`/`lowerHookResult` walk an element tree and lower it into
+plain protocol results. The README also spells out the operation model — an
+operation is a host-neutral use-case definition whose shared core
+(`id`/`inputSchema`/`execute`/`resultSchema`) runs identically under the CLI
+and MCP projections, `render` is consumed only by MCP, and the CLI prints
+validated JSON — and the npm `description` field no longer claims "React
+Server Component primitives". No runtime code or export surface changes.
diff --git a/docs/framework-mode.md b/docs/framework-mode.md
index 0b5f13bf2..af8add3b6 100644
--- a/docs/framework-mode.md
+++ b/docs/framework-mode.md
@@ -57,6 +57,103 @@ The server's structural declaration (`mcp.servers.runtime: {}`) lives in the
config; the name passed to `createRscMcpServer` only selects which operations
to serve.
+## One operation, end to end
+
+An **operation** is a host-neutral use-case definition: one named unit of
+work with a validated input, an implementation, a validated result, and a
+result renderer. It is *not* a CLI command — the CLI command and the MCP
+tool are optional projections declared alongside the shared core, and
+either (or both) may be present. The `status` operation used above looks
+like this in full, including the JSX:
+
+```tsx
+// src/operations/status.tsx
+import { defineOperation } from '@agent-bundle/rsc-runtime/plugin';
+import { Mcp } from '@agent-bundle/rsc-runtime';
+import { z } from 'zod';
+
+export const status = defineOperation({
+ // Shared core — both projections funnel through these four fields.
+ id: 'status',
+ inputSchema: z.object({ verbose: z.boolean().optional() }).strict(),
+ execute: async () => ({ status: 'ready' as const }),
+ resultSchema: z.object({ status: z.literal('ready') }).strict(),
+
+ // CLI projection — argv parsing, help text, exit codes. No JSX: the CLI
+ // prints the validated result as one line of JSON.
+ cli: {
+ name: 'status',
+ parse: (args) => (args.includes('--verbose') ? { verbose: true } : {}),
+ summary: 'Read runtime status.',
+ usage: 'status [--verbose]',
+ },
+
+ // MCP projection — tool metadata plus the result renderer. Only MCP
+ // consumes `render`, but it is a required field: a CLI-only operation
+ // still has to declare one.
+ mcp: {
+ description: 'Read runtime status.',
+ name: 'runtime_status',
+ readOnly: true,
+ server: 'runtime',
+ },
+ render: (result) => (
+
+ {`Runtime is ${result.status}.`}
+
+ ),
+});
+```
+
+Both projections run the identical pipeline —
+`inputSchema.parse(input)` → `execute(input, { signal })` →
+`resultSchema.parse(result)` — so inputs, implementation, and output
+validation cannot drift between surfaces. Only the last step differs:
+
+- **CLI** (`runRscCli`): `cli.parse` receives the arguments after the
+ command name and produces the input; the validated result is written to
+ stdout as one line of JSON (`JSON.stringify`), and `cli.exitCode(result)`
+ (default `0`) is returned to the entry, which sets it as the process exit
+ code. The CLI never touches `render` and never renders JSX.
+- **MCP** (`createRscMcpServer`): the tool handler calls `render(result)`
+ and `lowerMcpResult` synchronously lowers the returned React element tree
+ (`Mcp.Result`, `Mcp.Text`, `Mcp.Image`, `Mcp.Audio`, `Mcp.ResourceLink`,
+ `Mcp.EmbeddedResource`) into a plain MCP `CallToolResult` object. The
+ lowering is strict — `Mcp.Text` takes exactly one string child, hence the
+ template literal above.
+
+## Why `.tsx`, and what "RSC runtime" is not
+
+Despite the package name, `@agent-bundle/rsc-runtime` is **not a React
+Server Components renderer or runtime, and no Flight transport is
+involved**. Nothing streams a component tree to a client, hydrates, or
+holds 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, persistence, or application state; those remain
+explicit dependencies of `execute` implementations.
+
+Operation modules are `.tsx` for exactly one reason: the `render` callback
+returns JSX. Everything else in an operation — schemas, argv parsing, MCP
+metadata — is plain TypeScript, and modules with no runtime JSX (such as an
+application module that only composes operation arrays) stay `.ts`.
+
+For a new reader, in one breath:
+
+1. **What is an operation?** A host-neutral use-case definition — id, input
+ schema, `execute`, result schema, `render` — with optional CLI and MCP
+ projections.
+2. **Which parts are shared by CLI and MCP?** The core four: `id`,
+ `inputSchema`, `execute`, `resultSchema` (plus the validation pipeline
+ 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.
+5. **Why are operation modules `.tsx`?** Only because `render` returns JSX.
+
## Rendered skills (power tier, never required)
A skill whose document is generated: put `SKILL.tsx` (or `SKILL.ts`) in the
diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md
index 6475f709a..5fe9ae1c3 100644
--- a/examples/audiobook-curator/README.md
+++ b/examples/audiobook-curator/README.md
@@ -48,6 +48,36 @@ script, and lifecycle-wrapped MCP server) plus the npm package build beneath
`agent-bundle` and `@agent-bundle/rsc-runtime` exports with `workspace:*`
dependencies.
+## Operation model
+
+Every command is one `defineOperation` definition: a host-neutral use case —
+`id`, input schema, `execute`, result schema, `render` — with two
+projections declared beside it. The shared core runs identically on both
+surfaces (`inputSchema.parse` → `execute` → `resultSchema.parse`); `cli`
+adds argv parsing and exit codes, and `mcp` adds tool metadata. `render` is
+a sibling of both, required on every operation but consumed only by the MCP
+projection: the CLI prints each validated receipt as one line of JSON and
+never renders JSX.
+
+The runtime JSX for every operation is `` in
+[`src/result.tsx`](src/result.tsx), which wraps the receipt in the MCP
+result DSL:
+
+```tsx
+export const CuratorResult = ({ receipt }: { readonly receipt: CuratorReceipt }) => (
+
+ {summary(receipt)}
+
+);
+```
+
+`lowerMcpResult` lowers that element tree synchronously into an MCP
+`CallToolResult`. No React Server Components renderer or Flight transport is
+involved anywhere in this example; operation modules are `.tsx` only because
+`render` returns JSX, and `src/application.ts` stays `.ts` because it merely
+composes the operation arrays. The end-to-end walkthrough is in
+[Framework mode](../../docs/framework-mode.md).
+
## Source layout
- `agent-bundle.config.ts` — the structure: plugin identity, targets, the CLI
diff --git a/examples/audiobook-curator/src/application.ts b/examples/audiobook-curator/src/application.ts
index 7230c3e1e..8c695610d 100644
--- a/examples/audiobook-curator/src/application.ts
+++ b/examples/audiobook-curator/src/application.ts
@@ -3,7 +3,9 @@
* catalog. Structure — targets, the Skill, the CLI script, the MCP server —
* lives in `agent-bundle.config.ts` and file conventions; the operations
* themselves live in feature modules under `./operations/`, and this file
- * only merges their defaults.
+ * only merges their defaults. There is deliberately no JSX here: the only
+ * runtime JSX is each operation's `render`, which delegates to
+ * `` in `./result.tsx` for the MCP projection.
*/
import { defineRscApplication } from '@agent-bundle/rsc-runtime/plugin';
diff --git a/examples/audiobook-curator/src/result.tsx b/examples/audiobook-curator/src/result.tsx
index 42f23ebfa..6e7c1981c 100644
--- a/examples/audiobook-curator/src/result.tsx
+++ b/examples/audiobook-curator/src/result.tsx
@@ -1,3 +1,11 @@
+/**
+ * The one place runtime JSX lives: every operation's `render` wraps its
+ * receipt in ``, and the MCP projection lowers that element
+ * tree synchronously into a `CallToolResult` via `lowerMcpResult`. This is
+ * the MCP result DSL, not React Server Components — no renderer or Flight
+ * transport is involved, and the CLI projection never calls it (it prints
+ * the validated receipt as JSON instead).
+ */
import { Mcp } from '@agent-bundle/rsc-runtime';
import React from 'react';
diff --git a/packages/rsc-runtime/README.md b/packages/rsc-runtime/README.md
index 43f23800c..f9d3e7e12 100644
--- a/packages/rsc-runtime/README.md
+++ b/packages/rsc-runtime/README.md
@@ -4,6 +4,16 @@ Small React primitives for producing Agent Bundle hook and MCP protocol results
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).
+Despite the name, this package is **not a React Server Components renderer
+or runtime, and no Flight transport is involved**. It is 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.
+
```tsx
import { Mcp, lowerMcpResult } from '@agent-bundle/rsc-runtime';
@@ -78,6 +88,17 @@ export const application = defineRscApplication({
});
```
+An operation is a host-neutral use-case definition, not a CLI command: the
+shared core (`id`, `inputSchema`, `execute`, `resultSchema`) is what both
+projections run — `inputSchema.parse` → `execute` → `resultSchema.parse` —
+while `cli` and `mcp` are optional per-surface declarations. `render` is
+required on every operation but consumed only by the MCP projection, where
+`lowerMcpResult` synchronously lowers its element tree into the
+`CallToolResult`; the CLI never renders JSX and instead prints the
+validated result as one line of JSON. Operation modules are `.tsx` only
+because `render` returns JSX. The end-to-end walkthrough lives in
+[Framework mode](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/framework-mode.md).
+
Use `runRscCli(application, argv)` in the conventional `src/cli.ts` entry and
`createRscMcpServer(application, 'runtime')` in the conventional
`src/mcp/runtime.ts` entry. Operation inputs, implementations, output
diff --git a/packages/rsc-runtime/package.json b/packages/rsc-runtime/package.json
index 6e2862762..76c654d46 100644
--- a/packages/rsc-runtime/package.json
+++ b/packages/rsc-runtime/package.json
@@ -1,7 +1,7 @@
{
"name": "@agent-bundle/rsc-runtime",
"version": "0.0.0",
- "description": "React Server Component primitives for Agent Bundle hook and MCP results.",
+ "description": "React-element result primitives (an MCP result DSL) for Agent Bundle hook and MCP protocol results.",
"license": "MIT",
"keywords": [
"agent-bundle",
diff --git a/packages/rsc-runtime/tests/docs-contract.test.ts b/packages/rsc-runtime/tests/docs-contract.test.ts
new file mode 100644
index 000000000..6c6a1ff12
--- /dev/null
+++ b/packages/rsc-runtime/tests/docs-contract.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from '@rstest/core';
+import { createElement } from 'react';
+import { z } from 'zod';
+
+import { Mcp, defineOperation, defineRscApplication, lowerMcpResult, runRscCli } from '../src/index.js';
+
+/**
+ * The `status` operation printed in `docs/framework-mode.md` and this
+ * package's README, with a render counter so the "only MCP consumes
+ * `render`" claim is observable.
+ */
+const documentedStatus = (onRender: () => void) => defineOperation({
+ cli: {
+ name: 'status',
+ parse: (args) => (args.includes('--verbose') ? { verbose: true } : {}),
+ summary: 'Read runtime status.',
+ usage: 'status [--verbose]',
+ },
+ execute: async () => ({ status: 'ready' as const }),
+ id: 'status',
+ inputSchema: z.object({ verbose: z.boolean().optional() }).strict(),
+ mcp: {
+ description: 'Read runtime status.',
+ name: 'runtime_status',
+ readOnly: true,
+ server: 'runtime',
+ },
+ render: (result) => {
+ onRender();
+ return createElement(
+ Mcp.Result,
+ { structuredContent: result },
+ createElement(Mcp.Text, null, `Runtime is ${result.status}.`),
+ );
+ },
+ resultSchema: z.object({ status: z.literal('ready') }).strict(),
+});
+
+describe('documented operation model', () => {
+ it('serves both projections from one shared core and renders only for MCP', async () => {
+ let renders = 0;
+ const status = documentedStatus(() => {
+ renders += 1;
+ });
+ const application = defineRscApplication({
+ name: 'runtime',
+ operations: [status],
+ version: '0.1.0',
+ });
+ const output: string[] = [];
+
+ await expect(runRscCli(application, ['status'], { write: (value) => output.push(value) })).resolves.toBe(0);
+ // The CLI projection prints one line of JSON and never touches `render`.
+ expect(output.join('')).toBe('{"status":"ready"}\n');
+ expect(renders).toBe(0);
+
+ const result = await status.execute({}, { signal: new AbortController().signal });
+ expect(lowerMcpResult(status.render(result))).toEqual({
+ content: [{ text: 'Runtime is ready.', type: 'text' }],
+ structuredContent: { status: 'ready' },
+ });
+ expect(renders).toBe(1);
+ });
+
+ it('rejects the multi-child `Mcp.Text` the documented template literal avoids', () => {
+ expect(() => lowerMcpResult(createElement(
+ Mcp.Result,
+ null,
+ createElement(Mcp.Text, null, 'Runtime is ', 'ready', '.'),
+ ))).toThrow('mcp-text requires one text child');
+ });
+});