Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/shared-route-layouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agent-bundle": minor
"@agent-bundle/runtime": minor
---

Add the conventional shared layout module: `src/layout.{ts,tsx}` default-exports one component receiving `{ children, route, signal }` (`AgentLayoutProps` from `@agent-bundle/runtime`, with `AgentLayoutRoute` and `AgentLayoutRouteKind`) and wraps every rendered route — generated MCP tools, resources, and prompts, rendered `src/cli/**` commands, projected MCP commands, and rendered `src/scripts/*.tsx` — while `src/mcp/<server>/layout.{ts,tsx}` nests inside it for one generated server; event routes and App routes are never wrapped, servers pinned to `custom`, `command`, or `remote` skip their layout, and a project without a layout renders byte-identical MCP, CLI, and script output with an unchanged route-graph digest (generated worker source changes as in every release). `agent-bundle inspect --routes` lists layouts, `agent-bundle/test` (`renderRoute`, cli-dispatch, mcp-in-memory) composes the same chain the artifact bakes, and validation fails closed with `AB4830` (layout contract), `AB4831` (duplicate layout scope), and `AB4832` (orphaned server layout). Breaking for typed consumers: `AgentBundleTestManifest` gains the required `layouts` field and the test registry version is now 5. In `@agent-bundle/runtime`, `decodeAgentDocument` now treats an `Agent.Result` without a `value` as a container that adopts the value of the valued result it directly holds and merges plain-JSON object `metadata` with the container winning — a behavior change for documents that previously nested a valued result under a valueless root — so a layout shell leaves the route's result value, `structuredContent`, and content unchanged, while metadata a layout declares is projected as MCP `_meta` (#396)
9 changes: 7 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ gate a build, a validation, or a dev rebuild.
| `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). |
| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/<name>.mjs`; a host-emitted file collides with it (see below). |
| `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4906`) and commands `src/commands/*.md` (`AB4920`–`AB4926`); see below. |
| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), and provider conventions (see below). |
| `AB5000` | General CLI and adapter failures. |
| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). |
| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. |
Expand Down Expand Up @@ -450,11 +451,12 @@ canonicalized so the model digest is root-independent.
| `AB4925` | error | A command explicitly targets a host whose `commands` capability is `degraded`, `unavailable`, or `prohibited` (the message carries the host's reason). | Drop that host from the command's `targets`; Cursor and Claude publish command surfaces, Codex and portable do not. |
| `AB4926` | error | Two command files share a name. | Rename one file so every command name is unique. |

## Route graph, state, and provider conventions (`AB4800`–`AB4825`, `AB4940`–`AB4942`)
## Route graph, state, layout, and provider conventions (`AB4800`–`AB4832`, `AB4940`–`AB4942`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
`src/providers/*`, `src/cli/**`, `src/scripts/**`) into one immutable IR.
`src/providers/*`, `src/cli/**`, `src/scripts/**`) and the shared layout
modules (`src/layout.*`, `src/mcp/<server>/layout.*`) into one immutable IR.
Discovery is not a packaging choice, so every collision is a hard **error**
and the compiler never silently picks a side. Modules that explicit
`scripts`, `hooks`, `bin`, `lib`, or `mcp` configuration references are
Expand Down Expand Up @@ -640,6 +642,9 @@ schema constants), unions, nested objects, transforms, coercions — raises
| `AB4826` | error | A route's static `config` calls `appResourceUri('<app>')` with a reference that matches no App route of the route's own generated server with a static `config.resourceUri`: an unknown name, another server's App (a generated server registers only its own Apps), or a reference from a non-MCP route. The message names the cause and lists the server's known App route ids; reference the App as `'<app>'`, `'<server>/<app>'`, `'app:<server>/<app>'`, or a relative module path. |
| `AB4827` | error | An MCP App route's `config.template` is ambiguous or missing: both the route-relative and the project-root-relative interpretation name different existing files, or neither exists. The message names both candidate paths; templates resolve relative to the route module, so rewrite the path as `'./<file>.html'` beside the route. |
| `AB4828` | error | A generated MCP route advertises `_meta.ui.resourceUri` of an App on its server (through `appResourceUri()` or a literal) that is not built for every target the server ships to, because the App's `config.targets` (or a config-declared App's `targets`) is narrower. Widen the App's targets or restrict `mcp.servers.<server>.targets`. |
| `AB4830` | error | A conventional layout module (`src/layout.*`, `src/mcp/<server>/layout.*`) does not satisfy the layout contract: its default export is not a function component, it exports the route-only `config`/`inputSchema`/`resultSchema`, or it exports `execute`/`render`. Default-export one component receiving `{ children, route, signal }` that renders `Agent.Result` around `children`. |
| `AB4831` | error | Two layout modules declare one layout scope (for example `src/layout.ts` beside `src/layout.tsx`). Keep exactly one module per scope. |
| `AB4832` | error | A server layout (`src/mcp/<server>/layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.<server>` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. |
| `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. |
| `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. |
| `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. |
Expand Down
83 changes: 82 additions & 1 deletion docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ entries carry `provenance.kind: 'conventional'` in the normalized model.
| `src/events/<family>/<event>.{ts,tsx}`, `src/events/stop.{ts,tsx}` | Semantic event route: the path is the canonical event family (`src/events/tool/after.tsx` is `tool/after`; `stop` is the one top-level family) and must be one of the admitted `canonicalAgentEvents`. The optional static `config` (`AgentEventRouteConfig`: `targets`, `tools`, `runtime: 'shared' \| 'standalone'`, `fallback`, `delivery`, `timeoutMs`) restricts hosts and selects the execution mode; the async default Server Component receives `AgentEventRouteProps` (`{ canonical, native, signal }`) and returns `Agent.*` output that the selected host adapter encodes into its native hook envelope. Application code never branches on host JSON or emits native hook documents; per-host support is a capability state (`supported`/`degraded`/`unavailable`/`prohibited`) surfaced by `inspect` and enforced at build time (`AB4817`, `AB4823`–`AB4825`). | Restrict `config.targets`, or prefix a path segment with `_` |
| `src/state.ts` | Project state definition: default-exports `defineState({ ... })`; generated MCP, routed-CLI, and rendered-script request scopes mount `(await agent()).state` and `.notices`. | `state: false`, or rename the file to `_state.ts` |
| `src/providers/<name>.{ts,tsx}` | Request context provider: default-exports a factory receiving `{ invocation, signal }`; its value is mounted at `(await agent()).providers.<camelCaseName>` for generated MCP and event routes, projected MCP commands, plain and rendered routed CLI commands, and rendered scripts. | Prefix the file with `_` |
| `src/layout.{ts,tsx}` | Shared document layout: default-exports one component receiving `{ children, route, signal }` that renders `Agent.Result` around every rendered route — generated MCP tools, resources, and prompts, rendered routed CLI commands, projected MCP commands, and rendered scripts. Event routes are never wrapped. | Rename to `_layout.tsx` |
| `src/mcp/<server>/layout.{ts,tsx}` | Per-server layout nested inside the root layout for that generated server's routes. | Rename to `_layout.tsx`, or set `routes.servers.<server>` to a non-generated mode |

Route and package entry conventions match `.ts` and `.tsx` files exactly;
the state convention is specifically `src/state.ts`.
Expand Down Expand Up @@ -226,6 +228,85 @@ worker, so module-level provider state is shared across the simulated
executables of that worker and is only proven cold by the proof levels that
spawn the artifact.

### Shared layouts

A layout is the conventional composition point around every rendered route
of a project — the `layout.tsx` idea from page frameworks, applied to Agent
Documents. `src/layout.{ts,tsx}` wraps every rendered route (generated MCP
tools, resources, and prompts; rendered `src/cli/**` commands; projected MCP
commands; rendered `src/scripts/*.tsx`), and `src/mcp/<server>/layout.{ts,tsx}`
nests inside it for one generated server. Composition order is root layout,
then server layout, then the route. Event routes are host protocol responses
rather than documents for a reader, so no layout applies to them; browser
App routes are browser builds and are likewise untouched.

```tsx
// src/layout.tsx — the whole layout a consumer writes
import { Agent, type AgentLayoutProps } from '@agent-bundle/runtime';
import React from 'react';

export default function Layout({ children, route }: AgentLayoutProps) {
return (
<Agent.Result metadata={{ route: route.id }}>
{children}
</Agent.Result>
);
}
```

The layout renders `Agent.Result` around `children`, the route's rendered
element. An `Agent.Result` that declares no `value` is a **container**: when
it directly holds a result that does carry a value — the route's own
`<Agent.Result value={...}>` — the runtime merges the two while decoding the
document. The route's value becomes the document value, its children take the
inner result's place, and `metadata` combines: two JSON objects merge key by
key with the container winning conflicts, any other shape lets the container
win outright, and a container without metadata adopts the inner one. A route
therefore keeps its result value, its `structuredContent`, and its rendered
content whether or not a layout exists; what the layout adds is the shared
shell around it — a heading, a trailing `Agent.Context` note, document
metadata. Because the MCP projector exposes root metadata as the result's
`_meta`, a layout that declares metadata does change the MCP response there;
a layout without metadata leaves `_meta` exactly as the route authored it.
Nested layouts merge bottom-up the same way. Metadata on either side must be
plain JSON: a `Date`, class instance, accessor, or cyclic value fails the
document contract under a layout exactly as it does without one.

The generated worker resolves the route's element **before** the layout chain
renders, then wraps it. That keeps failure semantics identical with and
without a layout — a route that throws rejects the whole render (CLI exit 1
with the route's message, MCP transport failure) instead of being downgraded
to a represented `boundary` error beneath the layout's shell. The trade-off is
deliberate: a layout cannot stream a `Suspense` fallback around `children`
while the route is still running, because the route is never a lazily
resolved Flight chunk under the layout. A `Suspense` boundary a layout places
around its **own** content streams as usual.

`route` is the stable identity baked at compile time: `id` (`tool:curator/
inspect_sources`), `kind` (`tool`, `resource`, `prompt`, `cli`, `script`),
`name` (the protocol-facing tool/resource/prompt name, the space-joined
command path, or the script name), and `serverId` for MCP kinds. `signal` is
the request abort signal. Layouts render inside the same request scope as the
route, so `await agent()` exposes the invocation, host, session, actor,
workspace, state, and provider axes exactly as it does in a route. The props
type `AgentLayoutProps` ships from `@agent-bundle/runtime` (it carries React's
`ReactNode` children); `agent-bundle` exports the React-free `AgentLayoutRoute`
and `AgentLayoutRouteKind` identity types.

The compiler validates layouts statically: a layout whose default export is
not a function, or that carries the route contract's `config`/`inputSchema`/
`resultSchema` exports, fails with `AB4830`; `.ts` and `.tsx` siblings for
one scope fail with `AB4831`; a server layout whose server declares no tool,
resource, or prompt route modules (a missing server, or one with only `apps/`)
fails with `AB4832`, while a server pinned to `custom`, `command`, or `remote`
via `routes.servers.<server>` skips its layout entirely. At run time a layout module that resolves to a
non-function default export fails the request closed before rendering. The
route-unit and projection levels of `agent-bundle/test` compose the same
layout chain the generated workers bake, so `renderRoute('tool:...')` and
`invokeMcpTool(...)` prove the composed document; rendering a module passed
directly to `renderRoute()` composes no layout, because layouts are a compiler
convention rather than a property of the module.

### Handler request context

Conventional route components receive only their surface props, such as
Expand Down Expand Up @@ -353,7 +434,7 @@ export default async function inspect({ input, signal }: CliRouteProps<typeof in

The compiler statically projects `inputSchema` onto argv (the bounded grammar
and every policy rule are documented in
[Diagnostics](diagnostics.md#route-graph-state-and-provider-conventions-ab4800ab4822-ab4940ab4942)), generates nested
[Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4832-ab4940ab4942)), generates nested
help (`--help` at every level, `--version` at the root), and emits
`dist/bin/<plugin-name>.js` with the shebang and executable bit through the
same Rslib synthesis as every other bin. At run time the shell resolves the
Expand Down
8 changes: 8 additions & 0 deletions docs/framework-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ Agent Bundle has one newcomer model:
`execute`/`render` split.
4. **Opt in to context.** Call `await agent()` inside that component only when
host, session, actor, workspace, capability, or state context is needed.
5. **Share the shell once.** An optional `src/layout.tsx` (and
`src/mcp/<server>/layout.tsx` for one server) default-exports a component
receiving `{ children, route, signal }` (`AgentLayoutProps` from
`@agent-bundle/runtime`) and renders `Agent.Result` around
every rendered route; the route keeps its own `<Agent.Result value>`, and the
runtime merges the two so the result value and content are unchanged (layout
metadata surfaces as MCP `_meta`).
See [Shared layouts](entry-conventions.md#shared-layouts).

The complete conventional config is usually:

Expand Down
26 changes: 25 additions & 1 deletion examples/audiobook-curator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,38 @@ The routed CLI under `src/cli/` contains 16 authored commands. The
Projected tools accept one optional `--input '<JSON object>'`; tools annotated
read-only run directly, while mutation-capable tools require `--yes`.

### `src/layout.tsx` is the shared document shell

The conventional layout module wraps every rendered route once — the 16 MCP
tools, the catalog resource, the curate prompt, the rendered CLI commands, and
the projected `curator <tool>` commands — so no route imports a wrapper to get
the server's standard document structure. The layout renders a container
`Agent.Result` and the runtime merges each route's own
`<Agent.Result value={receipt}>` into it: the structured receipt, the MCP
content, and the CLI Markdown are exactly what the route declared, and the
shell contributes document metadata naming the producing route and surface,
which MCP hosts receive as `CallToolResult._meta.curator`. A route therefore
states only its value, its headline, and its report:

```tsx
export default async function Route({ input, signal }: ToolRouteProps<typeof inputSchema>) {
const receipt = await operation.handler(input, { signal }) as InventoryReceipt;
return (
<Agent.Result value={receipt}>
<Agent.Text>{inventoryHeadline(receipt)}</Agent.Text>
<InventoryShelf receipt={receipt} />
</Agent.Result>
);
}
```

### `src/components/` is the shared presentation library

The route modules perform domain work and compose these report components
instead of maintaining separate MCP and CLI presenters:

| Component | MCP composition | Rendered authored CLI composition |
| --- | --- | --- |
| `CuratorDocument` and its `CuratorReceipt` union | Wrap the structured receipt and headline for 15 receipt-bearing tools | Wrap `inventory`, `select`, `audible-search`, `convert`, `audit`, and `library-audit` |
| `DataList`, `Field`, `Callout`, and `FileList` | Provide atomic report fields, prose callouts, and file-list blocks throughout the component library and directly in the catalog resource, curate prompt, cache route, and library audit | Provide the same primitives through the shared components and directly in `library-audit` |
| `FileCard` and `EditionCard`, fed by `view-models` | Render file and edition models in `audit_library`, shelf, and ranking views | Reached through the receipt-specific shelves and ranking components |
| `InspectionShelf`, `InventoryShelf`, `AuditShelf`, and `SelectionShelf` | Compose receipt-specific inspection, inventory, audit, and selection reports; the inspection, inventory, and selection shelves are used directly by their MCP routes | `InventoryShelf` and `SelectionShelf` compose `inventory` and `select` |
Expand Down
10 changes: 4 additions & 6 deletions examples/audiobook-curator/src/cli/audible-search.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Agent } from '@agent-bundle/runtime';
import React from 'react';
import type { CliRouteConfig, CliRouteProps } from 'agent-bundle';
import { z } from 'zod';

import type { AudibleSearchReceipt } from '../audible.js';
import { SearchRanking } from '../components/candidate-ranking.js';
import { CuratorDocument } from '../components/curator-document.js';
import { audibleSearchHeadline } from '../components/headlines.js';
import { audibleOperations, audibleRegionList, defaultAudibleOperations } from '../operations/audible.js';

Expand Down Expand Up @@ -43,11 +43,9 @@ export default async function audibleSearch({ input, signal }: CliRouteProps<typ
title: input.title,
}, { signal }) as AudibleSearchReceipt;
return (
<CuratorDocument
headline={audibleSearchHeadline(receipt)}
receipt={receipt}
>
<Agent.Result value={receipt}>
<Agent.Text>{audibleSearchHeadline(receipt)}</Agent.Text>
<SearchRanking receipt={receipt} />
</CuratorDocument>
</Agent.Result>
);
}
Loading
Loading