From d600ea6e244cf2353caa66cb6cf56b65b409e0f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 21:12:21 +0000 Subject: [PATCH 1/3] docs(blueprints): propose React starter, view conversation and tic-tac-toe guide Add two draft feature Blueprints for design review: - getting_started_tracks: a qualified react-mcp/hono starter pair, a shorter setup guide with Lit notes and React tic-tac-toe tracks, the tic-tac-toe example played with the host's assistant, and the brand expansion change. - view_conversation: a delivery-neutral Conversation contract whose appendMessage lets a view hand the assistant its turn, implemented for MCP Apps ui/message and exposed on ViewHost in both stacks. Record the draft successor in application_setup's Status, index both designs, and add the turn-loop and conversation diagrams. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vus14ntFwS1mYMaD3siMkK --- blueprints/README.md | 1 + .../features/application_setup.blueprint.md | 2 + .../getting_started_tracks.blueprint.md | 239 ++++++++++++++++++ .../features/view_conversation.blueprint.md | 157 ++++++++++++ diagrams/tic-tac-toe-turn-loop.mmd | 26 ++ diagrams/tic-tac-toe-turn-loop.png | 3 + diagrams/tic-tac-toe-turn-loop.svg | 1 + diagrams/view-conversation.mmd | 10 + diagrams/view-conversation.png | 3 + diagrams/view-conversation.svg | 1 + 10 files changed, 443 insertions(+) create mode 100644 blueprints/features/getting_started_tracks.blueprint.md create mode 100644 blueprints/features/view_conversation.blueprint.md create mode 100644 diagrams/tic-tac-toe-turn-loop.mmd create mode 100644 diagrams/tic-tac-toe-turn-loop.png create mode 100644 diagrams/tic-tac-toe-turn-loop.svg create mode 100644 diagrams/view-conversation.mmd create mode 100644 diagrams/view-conversation.png create mode 100644 diagrams/view-conversation.svg diff --git a/blueprints/README.md b/blueprints/README.md index 4128c91b..4a1967cb 100644 --- a/blueprints/README.md +++ b/blueprints/README.md @@ -29,6 +29,7 @@ Use stable `snake_case` capability and module names. Codebase paths mirror the i | UI actions and rendering | [Action binding](modules/action_binding.blueprint.md) and [Lit adapter](features/reusable_lit_adapter.blueprint.md) | | Protected media | [Authorized playback](features/authorized_playback.blueprint.md) | | Project setup and host submission | [Application setup](features/application_setup.blueprint.md) and [host publication](features/host_publication.blueprint.md) | +| A React starter, the tic-tac-toe guide and messages from a view to the conversation | [Getting-started tracks](features/getting_started_tracks.blueprint.md) and [view conversation](features/view_conversation.blueprint.md) (both draft) | Each design's Status section separates its approved scope, implemented slices and outstanding acceptance. Host publication has working ChatGPT and Claude preparation commands; a fixture-verified local resumable-handoff record is also implemented, and none of these tools establishes publication. For working setup instructions, use [Getting started](../docs/getting-started.md). diff --git a/blueprints/features/application_setup.blueprint.md b/blueprints/features/application_setup.blueprint.md index 01e4c847..4cf2ba52 100644 --- a/blueprints/features/application_setup.blueprint.md +++ b/blueprints/features/application_setup.blueprint.md @@ -14,6 +14,8 @@ date_added: 2026-10-16 Hapi's direct-registration lifecycle amendment, [`6ac09eb2e7790179fc7e01ec561462e795b90469`](https://github.com/MachineWisdomAI/saga-a2ui/blob/6ac09eb2e7790179fc7e01ec561462e795b90469/blueprints/features/application_setup.blueprint.md), is implemented at `MachineWisdomAI/saga-a2ui@00e23b080493432250c9ae84c90385f5e5acc4f9` ([PR #215](https://github.com/MachineWisdomAI/saga-a2ui/pull/215)); [independent review](https://github.com/MachineWisdomAI/saga-a2ui/pull/215#pullrequestreview-5605193916) covered `072b3c2b1478cdbbb4c551a1fd2fc380e2e5cd6a`. +Successor (draft, not approved): [getting-started tracks](getting_started_tracks.blueprint.md) proposes to amend two parts of this design. It would add a qualified React/Hono starter pair (`templates/react-hono`, offered as `--a2ui-stack react-mcp --server hono`), and split the setup guide into a common guide plus Lit and React build tracks. Until it is approved, this design stays current, including its statement that no React pair is offered. + The seven SDK packages are published at [`0.7.7-rc.1`](https://github.com/MachineWisdomAI/saga-a2ui/releases/tag/v0.7.7-rc.1). Templates, the source CLI, demo and tour are development tooling, not npm packages or hosted services. Qualification of these outputs is local/installed evidence; it does not establish new OAuth, production or assistant-host acceptance. [Design and delivery history](#design-and-delivery-history) preserves the exact earlier scope and revisions. ## Problem and current behavior diff --git a/blueprints/features/getting_started_tracks.blueprint.md b/blueprints/features/getting_started_tracks.blueprint.md new file mode 100644 index 00000000..a2ae0700 --- /dev/null +++ b/blueprints/features/getting_started_tracks.blueprint.md @@ -0,0 +1,239 @@ +--- +feature_name: getting_started_tracks +status: draft +module_blueprints: [action_binding] +dependencies: [application_setup, view_conversation, view_composition, reusable_mcp_connector] +date_added: 2026-10-27 +--- + +# Getting started: a React starter and a tic-tac-toe guide + +## Status + +- Design: draft awaiting human approval. +- Implementation: not started. +- This would partially amend [application setup](application_setup.blueprint.md) in two places: a qualified React/Hono starter pair, and the split of the setup guide into a short common guide plus two build tracks. The approved application-setup contract stays in force for everything else, including the five Lit pairs, the demo, the CLI's safety behaviour and the generated existing-backend recipes. + + + +## Problem and current behavior + +A developer who looks at saga a2ui for ten minutes should come away knowing what they could build, and how. Today they meet package structure, protocol terms and contributor workflow before they see an app. + +| What they read | What they meet first | +| --- | --- | +| The [setup guide](../../docs/getting-started.md) | Published packages, candidate tarballs, HTTP defaults and evidence boundaries, all before a running app | +| The [action guide](../../docs/actions.md) | The notes application, which is thorough but long | + +React has a published stack and a [complete application example](../../docs/react-consumers.md#follow-a-complete-react-application), but no starter. The CLI's [supported-pair table](../../scripts/saga.ts) offers only `lit-mcp`, and its tests assert that `react-mcp` is refused. + +Nothing shows an assistant taking part in an app. Every current example is driven either by a view control or by a single tool call. + +The brand expansion is changing from "Safe Agentic Apps" to "Simple Agentic Apps". The framework is still **saga a2ui**, and **saga** once context is clear. An app built with it is still a **Saga**. + +## Proposed behavior + +```diff + docs/getting-started.md install, init, run, attach, troubleshoot +- + Lit composition, actions, React caveats, evidence ++docs/build-notes-with-lit.md the Lit composition material, leading into actions.md ++docs/build-tic-tac-toe-with-react.md a ten-minute guide that opens with the finished game +``` + +The new path for a React developer: + +```sh +npx tsx scripts/saga.ts init --a2ui-stack react-mcp --server hono ../my-saga +``` + +The command creates a clean React starter on Hono from `templates/react-hono`. It installs nothing and runs nothing. The tic-tac-toe guide starts from that output. + +**One game, from the guide.** + +1. The person asks the assistant for a game of tic-tac-toe. The assistant calls `new_game`, and the host shows the board. +2. The person clicks a square. The board calls `play_move` for X. The server checks the move and stores it, and the board shows it and marks the assistant's turn. +3. The board appends "I played X in row 1, column 2. Your move." to the conversation, using [view conversation](view_conversation.blueprint.md). The assistant takes its turn and calls `play_move` for O. +4. The board was already waiting for the assistant's move. It reads the game with `show_game`, shows O's square and gives the turn back to the person. +5. The game continues to a win or a draw. New game starts a separate game, and nothing from the old game can change it. + +![The person's click and the assistant's tool call reach the same play_move operation; the board learns each move through its own server calls and hands the turn over by appending a message.](../../diagrams/tic-tac-toe-turn-loop.svg) + +[Editable Mermaid](../../diagrams/tic-tac-toe-turn-loop.mmd) · [PNG](../../diagrams/tic-tac-toe-turn-loop.png) + +Both players' moves go through the same server operation. They reach the board differently, because the host routes a result by who called: + +- **The person's move.** The view makes the call, so the result comes back to the calling action's success hook. +- **The assistant's move.** The model makes the call. A host delivers a model call's result to a card it mounts for that call, and MCP Apps sends one tool input and result per mounted view, so the board that is already open is not guaranteed to receive it. The board therefore reads the game itself while it waits. + +`play_move` opens no card of its own, so the assistant's move does not add a second board to the conversation. + +## Responsibilities and interfaces + +### React/Hono starter (`templates/react-hono`) + +**Same as `templates/lit-hono`.** The template keeps the clean-starter contract: public status, a protected account operation whose verifier refuses every credential, the bound "Clear the note" local action and no fixtures. + +- **Server.** Its `server/` is the `lit-hono` text, with the same Hono 4.13.13, `@hono/node-server` 1.19.17 and `@modelcontextprotocol/hono` 2.0.2 pins. + - The one exception is the view-artifact check in `operations.ts`, which looks for ` + +## Problem and current behavior + +A rendered view can reach its application's server but cannot reach the conversation around it. Some apps need both. In a game played with the assistant, the person clicks a square and the server accepts the move, but the assistant does not learn that it is its turn. + +Both stacks start a view with `startA2uiView`. Each gives the application's `actions` callback a `ViewHost`: + +| Stack | `ViewHost` today | +| --- | --- | +| `@saga-a2ui/react-mcp-stack` | `server`: the server transport ([`start-a2ui-view.tsx`](../../packages/react-mcp-stack/src/start-a2ui-view.tsx)) | +| `@saga-a2ui/lit-mcp-stack` | `server`, and `links` for validated link opening ([`start-a2ui-view.ts`](../../packages/lit-mcp-stack/src/start-a2ui-view.ts)) | + +Neither kind of action can reach the conversation: + +- **Local action.** It runs in the view and changes only view state. +- **Server action.** Its `call` reaches the application's server through `App.callServerTool`, which the host proxies. + +The reply to a view-initiated call returns to the calling action. MCP Apps hosts do not add it to the conversation. The assistant also acts only when given a turn, so a view that changes shared state leaves the assistant unaware until the person types into the chat. + +The MCP Apps bridge the stacks depend on (`@modelcontextprotocol/ext-apps` 2.0.3) already supports this. `App.sendMessage` sends `ui/message`, a message with role `"user"` that the host adds to the conversation, and the host advertises it through its `message` capability. The stacks construct `App` privately, and the consumer boundary forbids applications from importing the bridge (`scripts/consumer-boundary.ts`). So an application has no supported way to add a message. + +## Proposed behavior + +```diff + actions: (host) => { + const play = Action.server('play-square', host.server) + .input((event) => ({square: Number(event.read('index'))})) + .run(({params, call}) => call('play_move', {...game.current(), ...params, player: 'X'})) + .onSuccess(({result, current}) => { + if (!current()) return; + game.present(result); +- // The assistant does not know the person moved. ++ if (game.isAssistantsTurn()) ++ void host.conversation.appendMessage(game.describeLastMove()).then((outcome) => { ++ if (outcome !== 'appended') game.say(TEXT.tellTheAssistant); ++ }); + }) + .build(); +``` + +The hook starts the append and does not wait for it. A host may wait for the person to confirm the message, and a success hook has a five-second budget ([action defaults](../../docs/actions.md)). The application handles the outcome when it arrives. + +The flow: + +1. The person clicks a square. The server accepts the move, and the action presents the new board. +2. The action appends one message, such as "I played X in row 1, column 2. Your move." +3. The host shows it in the conversation as if the person had typed it, and the assistant takes its turn. +4. If the host cannot take an appended message, the outcome says so and the application tells the person what to type. + +The capability follows the shape of host links. A delivery-neutral contract lives in `@saga-a2ui/core`, the MCP Apps implementation lives in `@saga-a2ui/mcp/view`, and each stack places an instance on `ViewHost`. + +![The application's action calls appendMessage on the core contract; the MCP Apps implementation sends ui/message when the host advertises message support; a future A2A delivery would send message/send in the current context.](../../diagrams/view-conversation.svg) + +[Editable Mermaid](../../diagrams/view-conversation.mmd) · [PNG](../../diagrams/view-conversation.png) + +## Responsibilities and interfaces + +**Contract (proposed, `@saga-a2ui/core/conversation`).** + +```ts +export type AppendOutcome = 'appended' | 'denied' | 'unavailable' | 'failed'; + +export interface Conversation { + /** Adds a message to the conversation as the person, which gives the assistant its turn. */ + appendMessage(text: string, options?: {readonly role?: 'user'}): Promise; +} + +export const NO_CONVERSATION: Conversation; // every call resolves 'unavailable' +``` + +| Outcome | Meaning | +| --- | --- | +| `appended` | The host accepted the message. It does not show that the assistant replied, or what it said. | +| `denied` | The host refused it, for example because the person declined or a policy blocked it. | +| `unavailable` | The host advertises no message capability, or the view has ended. Nothing was sent. | +| `failed` | The request could not complete, for example because the connection was lost or it timed out. | + +The verbs describe the conversation, not one delivery protocol: + +| Delivery | `appendMessage` sends | Status | +| --- | --- | --- | +| MCP Apps | `ui/message` with role `"user"` and one text content block, only when `getHostCapabilities().message` is present | Proposed | +| A2A | `message/send` with a `Message` of role `"user"` in the view's current `contextId` | Not proposed; the [A2A delivery issue](https://github.com/MachineWisdomAI/saga-a2ui/issues/198) owns it | + +`role` defaults to `'user'` and is the only accepted value. MCP Apps permits only that role. A2A's other role, `'agent'`, belongs to the agent, and the type widens only if a delivery needs it. + +The capability is read at call time, as host links do, so the instance can exist before the bridge has connected. + +**MCP Apps implementation (`@saga-a2ui/mcp/view`).** `conversationFromApp(app, signal)` sits beside `hostLinksFromApp`. It resolves `unavailable` once the view's lifetime signal has aborted, including while a request is outstanding, so an ended view hears no late outcome. + +**Stacks.** `ViewHost` gains a readonly `conversation: Conversation` in both stacks, built from the same `App` and lifetime signal as the existing members. React and Lit get it in the same release. + +**Application.** It owns everything about the conversation: + +- whether to append and what the text says; +- what the person sees when the outcome is not `appended`; +- never treating `appended` as the assistant having acted. + +Its server stays authoritative over application state. An appended message is a prompt, not a command, and it authorizes nothing. + +**Not included.** `updateModelContext`, which updates the model's context without starting a turn, is not proposed. The current examples do not need it, and A2A has no equivalent. It can follow under a general name when an application needs it. + +## Failure and recovery + +| Trigger | Observable result and recovery | +| --- | --- | +| The host does not advertise `message` | `unavailable`; nothing is sent. The application shows its own instruction, such as "Tell the assistant it's their move". | +| The host refuses the message | `denied`; the application shows the same instruction. No retry, because a repeated message would ask the assistant twice. | +| The request rejects or times out | `failed`; whether the host showed the message is unknown. The application shows the instruction and does not retry automatically. | +| The view ends while the request is outstanding | `unavailable`; the action's currentness checks keep the outcome from reaching a retired view. | +| The assistant does not respond, or responds without acting | Outside this capability. The application keeps its own state consistent and offers the person a way to continue. | + +## Verification outcomes + +- **Capability present.** A host fixture that advertises `message` and accepts the request: one `ui/message` with role `user` and the exact text is recorded; the outcome is `appended`. +- **Capability absent.** No request is sent; the outcome is `unavailable`. +- **Host refusal.** An `isError` answer resolves `denied`; a rejected request resolves `failed`. +- **Ended view.** Aborting the lifetime before or during the request resolves `unavailable`, and the late answer is ignored. +- **Parity.** The same cases pass through `ViewHost.conversation` from both stacks, in Chromium against the local MCP Apps host fixture (`apps/lit-host/e2e/host-fixture`). +- **Exports and boundary.** Each package's `exports.test.ts` lists the new entries, and consumer code reaches the capability without importing the bridge. + +These are local checks. Whether Claude, ChatGPT or Alexa+ show the message, and how their assistants respond, needs host runs. The design claims neither. + +## Implementation sequence + +1. Add the contract to `@saga-a2ui/core` and the MCP Apps implementation with its unit tests. +2. Add `ViewHost.conversation` to both stacks. Add a fixture handler that records messages and can withhold the capability, and add the browser cases. +3. Document it in the [React integration guide](../../docs/react-consumers.md) and the development reference. + +This release bumps the package versions. The [getting-started tracks](getting_started_tracks.blueprint.md) design uses it for the tic-tac-toe example. + +## Open decisions + +None in the design. Host behaviour is missing verification, not an open design choice. + +## References + +- [View composition](view_composition.blueprint.md) for the stacks and `startA2uiView`. +- [Action binding](../modules/action_binding.blueprint.md) for actions, hooks and currentness. +- `@saga-a2ui/core/host-links` and `@saga-a2ui/mcp/view` `hostLinksFromApp`: the pattern this follows. +- MCP Apps `App.sendMessage` and the host `message` capability in `@modelcontextprotocol/ext-apps` 2.0.3. A2A `message/send` and `Message.role`. diff --git a/diagrams/tic-tac-toe-turn-loop.mmd b/diagrams/tic-tac-toe-turn-loop.mmd new file mode 100644 index 00000000..18525dbc --- /dev/null +++ b/diagrams/tic-tac-toe-turn-loop.mmd @@ -0,0 +1,26 @@ +sequenceDiagram + actor P as Person + participant B as Board card (React view) + participant H as Host (Claude, ChatGPT, ...) + participant A as Assistant + participant S as Game server (Hono) + P->>B: clicks row 1, column 2 + B->>H: play_move(X, expectedMoveNumber 0) + H->>S: play_move + S-->>H: game, move 1, O to play + H-->>B: result to the calling action (onSuccess) + B->>B: show X, squares disabled + B->>H: appendMessage("I played X ... Your move.") + H->>A: the person's message + A->>H: play_move(O, expectedMoveNumber 1) + H->>S: play_move + S-->>H: game, move 2, X to play + H-->>A: result (no card opened) + loop bounded wait, about every 1.5 s + B->>H: show_game + H->>S: show_game + S-->>H: game, move 2 + H-->>B: result to the waiting board + end + B->>B: show O, squares enabled for X + Note over B,S: A stale card or repeated request sends an old expectedMoveNumber.
The server refuses it with no effect and returns the current game. diff --git a/diagrams/tic-tac-toe-turn-loop.png b/diagrams/tic-tac-toe-turn-loop.png new file mode 100644 index 00000000..2e5f63de --- /dev/null +++ b/diagrams/tic-tac-toe-turn-loop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8da65a6b605785c4a0154eac6a9c1f7994dc6f2d01566eba2fe3bc554647b6eb +size 224393 diff --git a/diagrams/tic-tac-toe-turn-loop.svg b/diagrams/tic-tac-toe-turn-loop.svg new file mode 100644 index 00000000..7f35817b --- /dev/null +++ b/diagrams/tic-tac-toe-turn-loop.svg @@ -0,0 +1 @@ +Game server (Hono)AssistantHost (Claude, ChatGPT, ...)Board card (React view)Game server (Hono)AssistantHost (Claude, ChatGPT, ...)Board card (React view)loop[bounded wait, about every 1.5 s]A stale card or repeated request sends an old expectedMoveNumber.The server refuses it with no effect and returns the current game.Personclicks row 1, column 2play_move(X, expectedMoveNumber 0)play_movegame, move 1, O to playresult to the calling action (onSuccess)show X, squares disabledappendMessage("I played X ... Your move.")the person's messageplay_move(O, expectedMoveNumber 1)play_movegame, move 2, X to playresult (no card opened)show_gameshow_gamegame, move 2result to the waiting boardshow O, squares enabled for XPerson diff --git a/diagrams/view-conversation.mmd b/diagrams/view-conversation.mmd new file mode 100644 index 00000000..9e830e74 --- /dev/null +++ b/diagrams/view-conversation.mmd @@ -0,0 +1,10 @@ +graph LR + APP["Application action
(onSuccess hook)"] + C["Conversation contract
@saga-a2ui/core/conversation
appendMessage(text, role: user)"] + ST["ViewHost.conversation
React and Lit stacks"] + M["MCP Apps implementation
@saga-a2ui/mcp/view"] + UI["ui/message, role user
only if the host advertises message"] + A2A["A2A delivery (not proposed)
message/send, role user, current contextId"] + APP --> ST --> C + C --> M --> UI + C -.-> A2A diff --git a/diagrams/view-conversation.png b/diagrams/view-conversation.png new file mode 100644 index 00000000..f4d5232a --- /dev/null +++ b/diagrams/view-conversation.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ad93d86a99c5761db36433734fa2741f1eb604637c9e3e4a4aaaa2ae500e8bf +size 97077 diff --git a/diagrams/view-conversation.svg b/diagrams/view-conversation.svg new file mode 100644 index 00000000..b7806a42 --- /dev/null +++ b/diagrams/view-conversation.svg @@ -0,0 +1 @@ +

Application action
(onSuccess hook)

Conversation contract
@saga-a2ui/core/conversation
appendMessage(text, role: user)

ViewHost.conversation
React and Lit stacks

MCP Apps implementation
@saga-a2ui/mcp/view

ui/message, role user
only if the host advertises message

A2A delivery (not proposed)
message/send, role user, current contextId

From 5daf12b5d4dbc1b41435b348c8d4a5c6abc03df8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 21:33:18 +0000 Subject: [PATCH 2/3] docs(blueprints): make play_move a saga operation without a view Settle the open decision: view becomes optional on operation declarations, so play_move stays a saga operation that opens no card. Record three requirements (identical execution guarantees, no UI association on definitions or results, one consumer interface), their verification, and that the change ships with the view-conversation SDK release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vus14ntFwS1mYMaD3siMkK --- .../getting_started_tracks.blueprint.md | 51 +++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/blueprints/features/getting_started_tracks.blueprint.md b/blueprints/features/getting_started_tracks.blueprint.md index a2ae0700..ed17c0fb 100644 --- a/blueprints/features/getting_started_tracks.blueprint.md +++ b/blueprints/features/getting_started_tracks.blueprint.md @@ -13,6 +13,7 @@ date_added: 2026-10-27 - Design: draft awaiting human approval. - Implementation: not started. - This would partially amend [application setup](application_setup.blueprint.md) in two places: a qualified React/Hono starter pair, and the split of the setup guide into a short common guide plus two build tracks. The approved application-setup contract stays in force for everything else, including the five Lit pairs, the demo, the CLI's safety behaviour and the generated existing-backend recipes. +- It would also make `view` optional on [reusable MCP connector](reusable_mcp_connector.blueprint.md) operation declarations, keeping every execution guarantee ([operations without a view](#operations-without-a-view)). @@ -149,6 +150,14 @@ The example is generated with the React/Hono `saga init` and keeps the template' | `show_game` | Assistant or board | Yes | `gameId` | The current game | | `play_move` | Assistant or board | No | `gameId`, `square` 0–8, `player` X or O, `expectedMoveNumber` | The game after the move | +All three are saga operations declared through `registerPublicOperation`. `play_move` simply omits `view` (see [operations without a view](#operations-without-a-view)): + +```diff + play_move: input, output, handle, project, failures +- view: boardView + show_game: input, output, handle, project, failures, view: boardView +``` + Every result carries the whole game as structured content, plus a short text for the model. `play_move`'s description tells the model three things: - it plays O; - it may place X only when the person states their move in conversation; @@ -172,6 +181,33 @@ Every result carries the whole game as structured content, plus a short text for - **`onToolResult`.** It presents the result that opened the card, and any later result a host does deliver to it. - **The guided change** adds a move list bound to the game's `history`. It changes the layout and catalog only. +### Operations without a view + +An operation's execution guarantees should not depend on whether it presents a card. `@saga-a2ui/mcp/server` already keeps the two apart: + +- **`execute()`** owns the request and operation time limits, cancellation, output validation and failure projection. +- **`register()`** declares the tool with its input and output contracts and annotations, so the MCP server validates input before the handler runs. It also attaches the view, which is the only view-specific step. + +So `view` becomes optional on operation declarations: + +```text +saga operation + validate input + execute within request and operation limits + validate and project the result + return success or a declared failure + with view → attach the UI resource + without view → return data and text +``` + +Three requirements govern it: + +1. **Identical execution guarantees.** Omitting `view` preserves input validation, budgets, cancellation handling and safe failures exactly. The tool is declared with the same input and output contracts and annotations, the same `execute()` path runs, and a protected operation still requires its identity resolver and sign-in failure text. +2. **No UI association.** A viewless operation registers no view resource. It attaches no UI-resource metadata to its tool definition, or to any result, whether successful or failed. +3. **One consumer interface.** The tutorial declares all three operations through saga. Any underlying plain MCP tool registration stays inside `@saga-a2ui/mcp`, and the example imports no MCP Apps server API. + +The change lands in step 2's SDK release. It amends the operation declaration described by the [reusable MCP connector](reusable_mcp_connector.blueprint.md), whose other guarantees are unchanged. + ## Failure and recovery | Trigger | Observable result and recovery | @@ -195,6 +231,12 @@ Every result carries the whole game as structured content, plus a short text for - In Chromium, the operation's result reaches the React view, Clear the note works without a server call, and teardown leaves the container empty. - Destination refusal, interruption and no-implicit-install still hold, and the five Lit pairs are unchanged. +**Operations without a view.** The same declaration is registered with and without `view`, and the two are compared: +- Invalid input, refusal, an absent record, an over-budget handler, cancellation, an asynchronous or malformed projection and invalid output give the same failures from both. +- The viewless tool's definition and every one of its results carry no `_meta.ui`, and the server lists no resource for it. +- A protected declaration without `view` still refuses a missing identity. +- The example's server and view import no `@modelcontextprotocol/ext-apps` module, and the boundary scan passes. + **Rules.** Deterministic tests cover wins on every line, the draw, turn order, and refusal of occupied, out-of-turn and post-game moves without changing the stored game. **Game view.** In Chromium, on the local MCP Apps host fixture: @@ -218,7 +260,7 @@ No test asserts which square an assistant chooses. ## Implementation sequence 1. **React/Hono starter.** The template, qualification, CLI row and setup-guide corrections. -2. **View conversation.** As specified in its own design. +2. **View conversation and operations without a view.** One SDK release: `appendMessage` as its own design specifies, and an optional `view` on operation declarations as described above. 3. **Tic-tac-toe example.** The game, its browser checks, and the guide's opening through the first click with a screenshot. The owner reviews this sample before the rest of the guide is written. 4. **The guide.** The rest of the tic-tac-toe guide, the setup-guide split, the Lit notes track and the README's first steps. 5. **The expansion change.** The current brand surfaces change to "Simple Agentic Apps", keeping lowercase framework naming, capitalised Saga for an app, and the existing artwork. Historical records keep their wording. @@ -227,10 +269,9 @@ Steps 1 and 2 are independent. Step 3 needs both. ## Open decisions -1. **How `play_move` opens no card.** An operation declaration requires a view today. - - **Recommended:** make `view` optional on public operation declarations in `@saga-a2ui/mcp/server`. An operation without one registers an ordinary tool with the same budgets, input contract and projection. This is part of step 2's SDK release. - - **Alternative:** register `play_move` with the MCP server's own `registerTool` in the example, which loses the operation budgets and failure projection. -2. **The wait's cadence and limit.** The values above are proposals. Host runs decide them. +1. **The wait's cadence and limit.** The values above are proposals. Host runs decide them. + +How `play_move` opens no card is settled: an optional `view` on saga operations, under the three requirements in [operations without a view](#operations-without-a-view). ## References From 92262ec8b706b238ef4302280d251514e6110124 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:10:21 +0000 Subject: [PATCH 3/3] docs(blueprints): pass game context with the move message and report finished games - appendMessage takes an optional context. MCP Apps sends ui/update-model-context, then ui/message. By default a context that cannot be delivered blocks the message; bestEffort opts into sending it anyway, and the result reports which stage stopped or was skipped. - The move message names the game and never restates the board. The accepted game travels as context, and the new viewless get_game operation is the assistant's fallback read and the board's wait. - A winning or drawing X move appends a result message and asks for no move. - Append results are applied only while the board still shows the same game and move, because an action's current() ends with its hook. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vus14ntFwS1mYMaD3siMkK --- .../getting_started_tracks.blueprint.md | 42 ++++-- .../features/view_conversation.blueprint.md | 121 ++++++++++++------ diagrams/tic-tac-toe-turn-loop.mmd | 38 ++++-- diagrams/tic-tac-toe-turn-loop.png | 4 +- diagrams/tic-tac-toe-turn-loop.svg | 2 +- diagrams/view-conversation.mmd | 9 +- diagrams/view-conversation.png | 4 +- diagrams/view-conversation.svg | 2 +- 8 files changed, 151 insertions(+), 71 deletions(-) diff --git a/blueprints/features/getting_started_tracks.blueprint.md b/blueprints/features/getting_started_tracks.blueprint.md index ed17c0fb..6bdfe159 100644 --- a/blueprints/features/getting_started_tracks.blueprint.md +++ b/blueprints/features/getting_started_tracks.blueprint.md @@ -53,9 +53,12 @@ The command creates a clean React starter on Hono from `templates/react-hono`. I 1. The person asks the assistant for a game of tic-tac-toe. The assistant calls `new_game`, and the host shows the board. 2. The person clicks a square. The board calls `play_move` for X. The server checks the move and stores it, and the board shows it and marks the assistant's turn. -3. The board appends "I played X in row 1, column 2. Your move." to the conversation, using [view conversation](view_conversation.blueprint.md). The assistant takes its turn and calls `play_move` for O. -4. The board was already waiting for the assistant's move. It reads the game with `show_game`, shows O's square and gives the turn back to the person. -5. The game continues to a win or a draw. New game starts a separate game, and nothing from the old game can change it. +3. If the game is still in progress, the board appends a short move message to the conversation, using [view conversation](view_conversation.blueprint.md). For example: "Your move in tic-tac-toe game ``. The board is in your context; if it isn't, read it with get_game. Then play O with play_move." + - The accepted game travels as the message's `context`, which the assistant receives and the conversation does not show. + - The message names the game and never restates the board. + - The assistant takes its turn. It reads `get_game` only if the context is missing, then calls `play_move` for O. +4. The board was already waiting for the assistant's move. It reads the game with `get_game`, shows O's square and gives the turn back to the person. +5. If X's move ends the game, the board instead appends the result as the person, such as "I won our tic-tac-toe game." or "Our tic-tac-toe game ended in a draw.", and starts no wait. New game starts a separate game, and nothing from the old game can change it. ![The person's click and the assistant's tool call reach the same play_move operation; the board learns each move through its own server calls and hands the turn over by appending a message.](../../diagrams/tic-tac-toe-turn-loop.svg) @@ -125,7 +128,7 @@ The arrangements it covers: - the same rules and state behind an MCP gateway on one server and an app API on another; - one A2UI layout rendered by React or Lit in the client; - a native A2UI host that needs no MCP; -- A2A as a delivery, including `appendMessage` mapped to A2A `message/send`; +- A2A as a delivery, including `appendMessage` mapped to A2A `message/send`, with its `context` as a data part on that message; - `GameStore` backed by Workers KV or a Durable Object when moving off a single Node process. ### Tic-tac-toe example (`examples/tic-tac-toe`) @@ -147,21 +150,27 @@ The example is generated with the React/Hono `saga init` and keeps the template' | Name | Called by | Opens a card | Input | Returns | | --- | --- | --- | --- | --- | | `new_game` | Assistant | Yes | — | A new game | -| `show_game` | Assistant or board | Yes | `gameId` | The current game | +| `show_game` | Assistant | Yes | `gameId` | Opens or reopens a card with the current game | +| `get_game` | Assistant or board | No | `gameId` | The current game | | `play_move` | Assistant or board | No | `gameId`, `square` 0–8, `player` X or O, `expectedMoveNumber` | The game after the move | -All three are saga operations declared through `registerPublicOperation`. `play_move` simply omits `view` (see [operations without a view](#operations-without-a-view)): +All four are saga operations declared through `registerPublicOperation`. `get_game` and `play_move` simply omit `view` (see [operations without a view](#operations-without-a-view)): ```diff play_move: input, output, handle, project, failures +- view: boardView + get_game: input, output, handle, project, failures - view: boardView show_game: input, output, handle, project, failures, view: boardView ``` -Every result carries the whole game as structured content, plus a short text for the model. `play_move`'s description tells the model three things: +The assistant needs a server read that opens no card. A host delivers a model call's result to a card mounted for that call, so reading through `show_game` would add a board to the conversation on every turn. Neither the app nor its server can push a result to the model: `AppBridge.sendToolResult` is the host's own notification to its view. + +Every result carries the whole game as structured content, plus a short text for the model. `play_move`'s description tells the model four things: - it plays O; - it may place X only when the person states their move in conversation; -- where a card cannot call the server, it should call `show_game` to display the board. +- if the board is not in its context, it reads it with `get_game`; +- where a card cannot call the server, it calls `show_game` to display the board. **Board view.** - **Layout.** One status line, three rows of three square buttons and a New game button, declared with `defineSurface`. @@ -173,9 +182,12 @@ Every result carries the whole game as structured content, plus a short text for - **The square action.** One server action is bound to every square: - its subject is the game and move number, so an old completion cannot reach a newer board; - it calls `play_move` for X; - - on success it presents the game, appends the hand-over message, and starts the wait. + - on success it presents the game; + - while the game is in progress, it appends the move message with the game as best-effort `context`, and starts the wait; + - when X's move ended the game, it appends the result message only; + - it applies the append's result only while the board still shows the same game and move, because the action's `current()` ends with the hook ([view conversation](view_conversation.blueprint.md#proposed-behavior)). - **The wait.** - - The controller owns it. It is a bounded series of `show_game` calls, about every second and a half for up to a minute. + - The controller owns it. It is a bounded series of `get_game` calls, about every second and a half for up to a minute. - It ends on the assistant's move, a new game, teardown or its limit. At the limit, a "Check for the assistant's move" button replaces it. - MCP Apps gives a server no way to push to an open view, so this is the mechanism available. - **`onToolResult`.** It presents the result that opened the card, and any later result a host does deliver to it. @@ -204,7 +216,7 @@ Three requirements govern it: 1. **Identical execution guarantees.** Omitting `view` preserves input validation, budgets, cancellation handling and safe failures exactly. The tool is declared with the same input and output contracts and annotations, the same `execute()` path runs, and a protected operation still requires its identity resolver and sign-in failure text. 2. **No UI association.** A viewless operation registers no view resource. It attaches no UI-resource metadata to its tool definition, or to any result, whether successful or failed. -3. **One consumer interface.** The tutorial declares all three operations through saga. Any underlying plain MCP tool registration stays inside `@saga-a2ui/mcp`, and the example imports no MCP Apps server API. +3. **One consumer interface.** The tutorial declares all four operations through saga. Any underlying plain MCP tool registration stays inside `@saga-a2ui/mcp`, and the example imports no MCP Apps server API. The change lands in step 2's SDK release. It amends the operation declaration described by the [reusable MCP connector](reusable_mcp_connector.blueprint.md), whose other guarantees are unchanged. @@ -216,6 +228,9 @@ The change lands in step 2's SDK release. It amends the operation declaration de | A stale card or a repeated request sends an old `expectedMoveNumber` | Refused with no effect. The card shows the current game, so no extra move is applied and a newer game is untouched. | | The `gameId` is unknown or expired | Refused. The card offers New game. | | The host cannot take an appended message, or refuses it | The status reads "Tell the assistant it's their move". The wait still runs. | +| The host cannot take the context | The move message is still sent, because the game opts into best effort. The assistant reads `get_game` as the message tells it. | +| X's move wins or fills the board | The board appends the result message, asks for no move and starts no wait. | +| An append settles after New game or a newer move | Its result is ignored; nothing appears on the newer game. | | The assistant does not move before the wait ends | "Check for the assistant's move" appears. Nothing is retried on the assistant's behalf. | | A view call fails or times out | The move may or may not have been applied. The card reads the game again before offering the square. | | The host shows the assistant's move in a new card | Both cards read the same game. An older card refuses its stale moves and catches up. | @@ -241,7 +256,10 @@ The change lands in step 2's SDK release. It amends the operation declaration de **Game view.** In Chromium, on the local MCP Apps host fixture: - clicking and keyboard moves; -- the recorded appended message; +- an in-progress move records the context update, with the game in `structuredContent`, and then the move message, which names the game and contains no board; +- with the context capability withheld, the move message is still recorded; +- a winning X move and a drawing X move each record exactly one result message, no context, and start no wait; +- a failed append that settles after New game shows nothing on the new game; - an O move made through the fixture and picked up by the wait; - stale and duplicate refusals; - a new game that an old request cannot change; diff --git a/blueprints/features/view_conversation.blueprint.md b/blueprints/features/view_conversation.blueprint.md index 0110c977..0d4de8d3 100644 --- a/blueprints/features/view_conversation.blueprint.md +++ b/blueprints/features/view_conversation.blueprint.md @@ -33,7 +33,7 @@ Neither kind of action can reach the conversation: The reply to a view-initiated call returns to the calling action. MCP Apps hosts do not add it to the conversation. The assistant also acts only when given a turn, so a view that changes shared state leaves the assistant unaware until the person types into the chat. -The MCP Apps bridge the stacks depend on (`@modelcontextprotocol/ext-apps` 2.0.3) already supports this. `App.sendMessage` sends `ui/message`, a message with role `"user"` that the host adds to the conversation, and the host advertises it through its `message` capability. The stacks construct `App` privately, and the consumer boundary forbids applications from importing the bridge (`scripts/consumer-boundary.ts`). So an application has no supported way to add a message. +The MCP Apps bridge the stacks depend on (`@modelcontextprotocol/ext-apps` 2.0.3) already supports this. `App.sendMessage` sends `ui/message`, a message with role `"user"` that the host adds to the conversation, and the host advertises it through its `message` capability. `App.updateModelContext` sends `ui/update-model-context`: information that the host passes to the model with the next user message, advertised as `updateModelContext`. The stacks construct `App` privately, and the consumer boundary forbids applications from importing the bridge (`scripts/consumer-boundary.ts`). So an application has no supported way to add a message. ## Proposed behavior @@ -44,28 +44,37 @@ The MCP Apps bridge the stacks depend on (`@modelcontextprotocol/ext-apps` 2.0.3 .run(({params, call}) => call('play_move', {...game.current(), ...params, player: 'X'})) .onSuccess(({result, current}) => { if (!current()) return; - game.present(result); + const game = board.present(result); - // The assistant does not know the person moved. -+ if (game.isAssistantsTurn()) -+ void host.conversation.appendMessage(game.describeLastMove()).then((outcome) => { -+ if (outcome !== 'appended') game.say(TEXT.tellTheAssistant); -+ }); ++ const shown = {gameId: game.gameId, moveNumber: game.moveNumber}; ++ const handOver = ++ game.status === 'playing' ++ ? host.conversation.appendMessage(TEXT.yourMove(game.gameId), { ++ // best effort: the move message tells the assistant to read get_game if the board is missing ++ context: {text: TEXT.boardForAssistant(game), data: {game}, bestEffort: true}, ++ }) ++ : host.conversation.appendMessage(TEXT.result(game)); // reports the result, asks for no move ++ void handOver.then((result) => { ++ if (!board.isShowing(shown)) return; // a newer game or move is on screen ++ if (result.outcome !== 'appended') board.say(TEXT.tellTheAssistant); ++ }); }) .build(); ``` -The hook starts the append and does not wait for it. A host may wait for the person to confirm the message, and a success hook has a five-second budget ([action defaults](../../docs/actions.md)). The application handles the outcome when it arrives. - -The flow: +**The flow:** 1. The person clicks a square. The server accepts the move, and the action presents the new board. -2. The action appends one message, such as "I played X in row 1, column 2. Your move." -3. The host shows it in the conversation as if the person had typed it, and the assistant takes its turn. -4. If the host cannot take an appended message, the outcome says so and the application tells the person what to type. +2. **If the game is still in progress**, the action appends a short move message: "Your move in tic-tac-toe game ``. The board is in your context; if it isn't, read it with get_game. Then play O with play_move." The accepted game travels as the message's `context`, which the assistant receives but the conversation does not show. The message names the game; it never restates the board, because the server owns the state. +3. **If X's move ended the game**, the action instead appends the result, as the person: "I won our tic-tac-toe game." or "Our tic-tac-toe game ended in a draw." It asks for no move. Every draw ends on X's move, because X moves first on nine squares. An O win arrives through the assistant's own call and needs no message. +4. The host shows the message as if the person had typed it, and the assistant takes its turn. +5. If nothing was appended, the result says so, and the application tells the person what to type. + +**Why the action rechecks with its own identity.** The hook starts the append and does not wait for it. A host may wait for the person to confirm the message, and a success hook has a five-second budget ([action defaults](../../docs/actions.md)). The action's `current()` is true only while the runtime waits for this hook ([action guide](../../docs/actions.md)). It is already false when the append settles. So the application compares its own `{gameId, moveNumber}` with what the board shows. A late failure for an old game or move never appears on a newer one. The capability follows the shape of host links. A delivery-neutral contract lives in `@saga-a2ui/core`, the MCP Apps implementation lives in `@saga-a2ui/mcp/view`, and each stack places an instance on `ViewHost`. -![The application's action calls appendMessage on the core contract; the MCP Apps implementation sends ui/message when the host advertises message support; a future A2A delivery would send message/send in the current context.](../../diagrams/view-conversation.svg) +![The application's action calls appendMessage, optionally with context, on the core contract; the MCP Apps implementation sends ui/update-model-context and then ui/message when the host supports them; a future A2A delivery would send one message/send with the context as a data part.](../../diagrams/view-conversation.svg) [Editable Mermaid](../../diagrams/view-conversation.mmd) · [PNG](../../diagrams/view-conversation.png) @@ -74,64 +83,104 @@ The capability follows the shape of host links. A delivery-neutral contract live **Contract (proposed, `@saga-a2ui/core/conversation`).** ```ts -export type AppendOutcome = 'appended' | 'denied' | 'unavailable' | 'failed'; +export type RequestOutcome = 'denied' | 'unavailable' | 'failed'; + +export type AppendResult = + | {readonly outcome: 'appended'; readonly context?: RequestOutcome} // context set only when best effort skipped it + | {readonly outcome: RequestOutcome; readonly stage: 'context' | 'message'}; // nothing appended export interface Conversation { - /** Adds a message to the conversation as the person, which gives the assistant its turn. */ - appendMessage(text: string, options?: {readonly role?: 'user'}): Promise; + /** + * Adds a message to the conversation as the person, which gives the assistant its turn. + * `context` is information for that turn that the conversation does not show. By default the + * message is sent only if the context was accepted; `bestEffort: true` sends it regardless. + */ + appendMessage( + text: string, + options?: { + readonly role?: 'user'; + readonly context?: { + readonly text: string; + readonly data?: Record; + readonly bestEffort?: boolean; // default false + }; + }, + ): Promise; } -export const NO_CONVERSATION: Conversation; // every call resolves 'unavailable' +export const NO_CONVERSATION: Conversation; // every call resolves {outcome: 'unavailable', stage: ...} ``` | Outcome | Meaning | | --- | --- | | `appended` | The host accepted the message. It does not show that the assistant replied, or what it said. | -| `denied` | The host refused it, for example because the person declined or a policy blocked it. | -| `unavailable` | The host advertises no message capability, or the view has ended. Nothing was sent. | +| `denied` | The host refused the request, for example because the person declined or a policy blocked it. | +| `unavailable` | The host advertises no capability for the request, or the view has ended. That request was not sent. | | `failed` | The request could not complete, for example because the connection was lost or it timed out. | +`stage` says which request stopped the append. `context` on an appended result says why a best-effort context was skipped. + +**Context blocks the message by default.** When `context` is present and cannot be delivered, because it is unsupported, refused, failed or the view ended, the message is not sent. The result is `{outcome, stage: 'context'}`, so the application knows nothing reached the conversation. + +With `bestEffort: true`, the application opts in to sending the message anyway. The result is `{outcome: 'appended', context: }`. An application should opt in only when the assistant has another way to get the information. The tic-tac-toe game opts in because its move message names `get_game`. + +**One verb, not two.** Context is a parameter of the message, not a separate request. +- An MCP Apps host "will typically defer sending the context to the model until the next user message… Only the last update is sent" (`ext-apps` 2.0.3 `App.updateModelContext`). +- So context without a following message has no effect the application can rely on. +- A2A carries both in one `message/send`. + The verbs describe the conversation, not one delivery protocol: | Delivery | `appendMessage` sends | Status | | --- | --- | --- | -| MCP Apps | `ui/message` with role `"user"` and one text content block, only when `getHostCapabilities().message` is present | Proposed | -| A2A | `message/send` with a `Message` of role `"user"` in the view's current `contextId` | Not proposed; the [A2A delivery issue](https://github.com/MachineWisdomAI/saga-a2ui/issues/198) owns it | +| MCP Apps | With `context`: `ui/update-model-context` first, `text` as a content block and `data` as `structuredContent`, only when `getHostCapabilities().updateModelContext` is present. Then `ui/message` with role `"user"` and one text content block, only when `getHostCapabilities().message` is present. | Proposed | +| A2A | One `message/send` with a `Message` of role `"user"` in the view's current `contextId`. `context` would become a data part on that message. | Not proposed; the [A2A delivery issue](https://github.com/MachineWisdomAI/saga-a2ui/issues/198) owns it | + +**Trust.** Context comes from the view. It is a snapshot that helps the assistant, not authority. The server still validates every move, including its `expectedMoveNumber`. `role` defaults to `'user'` and is the only accepted value. MCP Apps permits only that role. A2A's other role, `'agent'`, belongs to the agent, and the type widens only if a delivery needs it. -The capability is read at call time, as host links do, so the instance can exist before the bridge has connected. +Capabilities are read at call time, as host links do, so the instance can exist before the bridge has connected. -**MCP Apps implementation (`@saga-a2ui/mcp/view`).** `conversationFromApp(app, signal)` sits beside `hostLinksFromApp`. It resolves `unavailable` once the view's lifetime signal has aborted, including while a request is outstanding, so an ended view hears no late outcome. +**MCP Apps implementation (`@saga-a2ui/mcp/view`).** `conversationFromApp(app, signal)` sits beside `hostLinksFromApp`. It sends the context request, then the message, never in parallel. Once the view's lifetime signal has aborted, it resolves `unavailable`, including while a request is outstanding, so an ended view hears no late outcome. **Stacks.** `ViewHost` gains a readonly `conversation: Conversation` in both stacks, built from the same `App` and lifetime signal as the existing members. React and Lit get it in the same release. **Application.** It owns everything about the conversation: -- whether to append and what the text says; -- what the person sees when the outcome is not `appended`; +- whether to append, what the text says, and what context goes with it; +- whether a context failure may be skipped (`bestEffort`); +- what the person sees when nothing was appended, shown only while the same state is on screen; - never treating `appended` as the assistant having acted. Its server stays authoritative over application state. An appended message is a prompt, not a command, and it authorizes nothing. -**Not included.** `updateModelContext`, which updates the model's context without starting a turn, is not proposed. The current examples do not need it, and A2A has no equivalent. It can follow under a general name when an application needs it. - ## Failure and recovery | Trigger | Observable result and recovery | | --- | --- | -| The host does not advertise `message` | `unavailable`; nothing is sent. The application shows its own instruction, such as "Tell the assistant it's their move". | -| The host refuses the message | `denied`; the application shows the same instruction. No retry, because a repeated message would ask the assistant twice. | +| The host does not advertise `message` | `{outcome: 'unavailable', stage: 'message'}`. Nothing is sent. The application shows its own instruction, such as "Tell the assistant it's their move". | +| The context cannot be delivered (unsupported, refused or failed) | By default, `{outcome, stage: 'context'}`: the message is not sent, and the application shows its instruction. With `bestEffort`, the message is sent and the result is `{outcome: 'appended', context}`. | +| The host refuses the message | `denied` at the message stage; the application shows the same instruction. No retry, because a repeated message would ask the assistant twice. | | The request rejects or times out | `failed`; whether the host showed the message is unknown. The application shows the instruction and does not retry automatically. | -| The view ends while the request is outstanding | `unavailable`; the action's currentness checks keep the outcome from reaching a retired view. | +| The view ends while a request is outstanding | `unavailable`. No later request is sent. | +| The outcome settles after the application moved on, for example to a new game | The application's own identity check finds different state on screen and ignores the outcome. | | The assistant does not respond, or responds without acting | Outside this capability. The application keeps its own state consistent and offers the person a way to continue. | ## Verification outcomes -- **Capability present.** A host fixture that advertises `message` and accepts the request: one `ui/message` with role `user` and the exact text is recorded; the outcome is `appended`. -- **Capability absent.** No request is sent; the outcome is `unavailable`. -- **Host refusal.** An `isError` answer resolves `denied`; a rejected request resolves `failed`. -- **Ended view.** Aborting the lifetime before or during the request resolves `unavailable`, and the late answer is ignored. +- **Message only.** + - A fixture that advertises `message` and accepts the request records one `ui/message` with role `user` and the exact text, and the outcome is `appended`. + - No context request is made. +- **With context.** The fixture records `ui/update-model-context` with `text` as content and `data` as `structuredContent`, and then `ui/message`, in that order. +- **Context fails, default.** The context capability is withheld, refused (`isError`) or rejected. + - Nothing reaches `ui/message`. + - The result is `{outcome: 'unavailable' | 'denied' | 'failed', stage: 'context'}`. +- **Context fails, best effort.** The same three cases. + - Only `ui/message` is recorded. + - The result is `{outcome: 'appended', context: }`. +- **Message refused or failing.** An `isError` answer resolves `denied` and a rejected request resolves `failed`, both at the message stage. A missing `message` capability resolves `unavailable` at the message stage. +- **Ended view.** Aborting the lifetime before or during either request resolves `unavailable`, sends nothing further and ignores the late answer. - **Parity.** The same cases pass through `ViewHost.conversation` from both stacks, in Chromium against the local MCP Apps host fixture (`apps/lit-host/e2e/host-fixture`). - **Exports and boundary.** Each package's `exports.test.ts` lists the new entries, and consumer code reaches the capability without importing the bridge. @@ -140,7 +189,7 @@ These are local checks. Whether Claude, ChatGPT or Alexa+ show the message, and ## Implementation sequence 1. Add the contract to `@saga-a2ui/core` and the MCP Apps implementation with its unit tests. -2. Add `ViewHost.conversation` to both stacks. Add a fixture handler that records messages and can withhold the capability, and add the browser cases. +2. Add `ViewHost.conversation` to both stacks. Add fixture handlers that record context updates and messages in order, and that can withhold, refuse or fail each one. Add the browser cases. 3. Document it in the [React integration guide](../../docs/react-consumers.md) and the development reference. This release bumps the package versions. The [getting-started tracks](getting_started_tracks.blueprint.md) design uses it for the tic-tac-toe example. @@ -154,4 +203,4 @@ None in the design. Host behaviour is missing verification, not an open design c - [View composition](view_composition.blueprint.md) for the stacks and `startA2uiView`. - [Action binding](../modules/action_binding.blueprint.md) for actions, hooks and currentness. - `@saga-a2ui/core/host-links` and `@saga-a2ui/mcp/view` `hostLinksFromApp`: the pattern this follows. -- MCP Apps `App.sendMessage` and the host `message` capability in `@modelcontextprotocol/ext-apps` 2.0.3. A2A `message/send` and `Message.role`. +- MCP Apps `App.sendMessage`, `App.updateModelContext` and the host `message` and `updateModelContext` capabilities in `@modelcontextprotocol/ext-apps` 2.0.3. A2A `message/send`, `Message.role` and data parts. diff --git a/diagrams/tic-tac-toe-turn-loop.mmd b/diagrams/tic-tac-toe-turn-loop.mmd index 18525dbc..618671d3 100644 --- a/diagrams/tic-tac-toe-turn-loop.mmd +++ b/diagrams/tic-tac-toe-turn-loop.mmd @@ -7,20 +7,32 @@ sequenceDiagram P->>B: clicks row 1, column 2 B->>H: play_move(X, expectedMoveNumber 0) H->>S: play_move - S-->>H: game, move 1, O to play + S-->>H: game, move 1 H-->>B: result to the calling action (onSuccess) B->>B: show X, squares disabled - B->>H: appendMessage("I played X ... Your move.") - H->>A: the person's message - A->>H: play_move(O, expectedMoveNumber 1) - H->>S: play_move - S-->>H: game, move 2, X to play - H-->>A: result (no card opened) - loop bounded wait, about every 1.5 s - B->>H: show_game - H->>S: show_game + alt game in progress, O next + B->>H: appendMessage("Your move in game ...", context: game) + Note over B,H: ui/update-model-context, then ui/message + H->>A: the person's message, with the game as context + opt context missing + A->>H: get_game + H->>S: get_game + S-->>H: game, move 1 + H-->>A: result + end + A->>H: play_move(O, expectedMoveNumber 1) + H->>S: play_move S-->>H: game, move 2 - H-->>B: result to the waiting board + H-->>A: result (no card opened) + loop bounded wait, about every 1.5 s + B->>H: get_game + H->>S: get_game + S-->>H: game, move 2 + H-->>B: result to the waiting board + end + B->>B: show O, squares enabled for X + else X won, or the board is full + B->>H: appendMessage("I won our tic-tac-toe game.") + H->>A: the person's message (no move asked) end - B->>B: show O, squares enabled for X - Note over B,S: A stale card or repeated request sends an old expectedMoveNumber.
The server refuses it with no effect and returns the current game. + Note over B,S: A late append result is ignored unless the board still shows the same game and move.
A stale card or repeated request sends an old expectedMoveNumber, which the server refuses with no effect. diff --git a/diagrams/tic-tac-toe-turn-loop.png b/diagrams/tic-tac-toe-turn-loop.png index 2e5f63de..c3b6f9bf 100644 --- a/diagrams/tic-tac-toe-turn-loop.png +++ b/diagrams/tic-tac-toe-turn-loop.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8da65a6b605785c4a0154eac6a9c1f7994dc6f2d01566eba2fe3bc554647b6eb -size 224393 +oid sha256:c5e8de69d1a31e28cd84eb34cda9b3b623dc2bb6efeac6c89e8a525668945424 +size 282075 diff --git a/diagrams/tic-tac-toe-turn-loop.svg b/diagrams/tic-tac-toe-turn-loop.svg index 7f35817b..dd036754 100644 --- a/diagrams/tic-tac-toe-turn-loop.svg +++ b/diagrams/tic-tac-toe-turn-loop.svg @@ -1 +1 @@ -Game server (Hono)AssistantHost (Claude, ChatGPT, ...)Board card (React view)Game server (Hono)AssistantHost (Claude, ChatGPT, ...)Board card (React view)loop[bounded wait, about every 1.5 s]A stale card or repeated request sends an old expectedMoveNumber.The server refuses it with no effect and returns the current game.Personclicks row 1, column 2play_move(X, expectedMoveNumber 0)play_movegame, move 1, O to playresult to the calling action (onSuccess)show X, squares disabledappendMessage("I played X ... Your move.")the person's messageplay_move(O, expectedMoveNumber 1)play_movegame, move 2, X to playresult (no card opened)show_gameshow_gamegame, move 2result to the waiting boardshow O, squares enabled for XPerson +Game server (Hono)AssistantHost (Claude, ChatGPT, ...)Board card (React view)Game server (Hono)AssistantHost (Claude, ChatGPT, ...)Board card (React view)ui/update-model-context, then ui/messageopt[context missing]loop[bounded wait, about every 1.5 s]alt[game in progress, O next][X won, or the board is full]A late append result is ignored unless the board still shows the same game and move.A stale card or repeated request sends an old expectedMoveNumber, which the server refuses with no effect.Personclicks row 1, column 2play_move(X, expectedMoveNumber 0)play_movegame, move 1result to the calling action (onSuccess)show X, squares disabledappendMessage("Your move in game ...", context: game)the person's message, with the game as contextget_gameget_gamegame, move 1resultplay_move(O, expectedMoveNumber 1)play_movegame, move 2result (no card opened)get_gameget_gamegame, move 2result to the waiting boardshow O, squares enabled for XappendMessage("I won our tic-tac-toe game.")the person's message (no move asked)Person diff --git a/diagrams/view-conversation.mmd b/diagrams/view-conversation.mmd index 9e830e74..cd068e9a 100644 --- a/diagrams/view-conversation.mmd +++ b/diagrams/view-conversation.mmd @@ -1,10 +1,11 @@ graph LR APP["Application action
(onSuccess hook)"] - C["Conversation contract
@saga-a2ui/core/conversation
appendMessage(text, role: user)"] ST["ViewHost.conversation
React and Lit stacks"] + C["Conversation contract
@saga-a2ui/core/conversation
appendMessage(text, role?, context?)"] M["MCP Apps implementation
@saga-a2ui/mcp/view"] - UI["ui/message, role user
only if the host advertises message"] - A2A["A2A delivery (not proposed)
message/send, role user, current contextId"] + CTX["ui/update-model-context
if context, when supported
(failure blocks the message
unless bestEffort)"] + UI["ui/message, role user
when the host advertises message"] + A2A["A2A delivery (not proposed)
one message/send, role user,
context as a data part"] APP --> ST --> C - C --> M --> UI + C --> M --> CTX --> UI C -.-> A2A diff --git a/diagrams/view-conversation.png b/diagrams/view-conversation.png index f4d5232a..7995e648 100644 --- a/diagrams/view-conversation.png +++ b/diagrams/view-conversation.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6ad93d86a99c5761db36433734fa2741f1eb604637c9e3e4a4aaaa2ae500e8bf -size 97077 +oid sha256:4f8f83c8e3a4e3e31352692a9dad5b8aa58da64bb7d8b6c07463dc3f0e81a340 +size 99585 diff --git a/diagrams/view-conversation.svg b/diagrams/view-conversation.svg index b7806a42..951cdd2a 100644 --- a/diagrams/view-conversation.svg +++ b/diagrams/view-conversation.svg @@ -1 +1 @@ -

Application action
(onSuccess hook)

Conversation contract
@saga-a2ui/core/conversation
appendMessage(text, role: user)

ViewHost.conversation
React and Lit stacks

MCP Apps implementation
@saga-a2ui/mcp/view

ui/message, role user
only if the host advertises message

A2A delivery (not proposed)
message/send, role user, current contextId

+

Application action
(onSuccess hook)

ViewHost.conversation
React and Lit stacks

Conversation contract
@saga-a2ui/core/conversation
appendMessage(text, role?, context?)

MCP Apps implementation
@saga-a2ui/mcp/view

ui/update-model-context
if context, when supported
(failure blocks the message
unless bestEffort)

ui/message, role user
when the host advertises message

A2A delivery (not proposed)
one message/send, role user,
context as a data part