Skip to content

ADR-0021: Unified Direct-Apply Rail for Preview→Apply Cards

Status

Accepted

Date: 2026-05-21

Supersedes ADR-0015 (the SendMessage re-issue rail) for the apply-button rail. ADR-0015's destructiveHint policy table remains in force.

Context

ADR-0015 settled the preview→apply pattern with a two-step UI: a preview card with Confirm/Cancel buttons whose on_click fired

  • SetState("pending", True) + SendMessage("Apply: call <tool>(<args>, preview=False)") on Confirm — instructing the agent to re-issue the apply tool call,
  • SetState("cancelled", True) + SendMessage("Cancel: do not apply <description>.") on Cancel — telling the agent the user opted out.

The reason for the agent round-trip — explained in ADR-0015 — was that the 2025-11-25 MCP Tools spec routed an iframe-initiated tools/call result back to the iframe, not to the agent's model context. Without agent re-issue, successes and errors never reached the agent: closing #545 and #559 required the agent to be the caller.

That constraint dissolved with the MCP Apps spec, SEP-1865 (2026-01-26). The new ui/update-model-context notification lets an iframe push structured content into the agent's model context for the agent's next turn. The iframe can now fire the apply call and deliver the structured response — both of the things ADR-0015 said were impossible.

Once ui/update-model-context shipped, we ran a controlled rollout of the new rail (the "direct-apply rail") on create_purchase_order and the modify_* / delete_* / correct_* / stock-adjustment write tools. It worked, so we extended it to most tools — but fulfill_order, receive_purchase_order, create_stock_transfer, and the batch-recipe-update card stayed on the SendMessage rail (the ADR-0015 default), giving us two visibly different rails:

  • Direct-apply rail: Confirm fires CallTool(... preview=False, on_success=[..., UpdateContext($result), ...]) → iframe morphs to the applied card in place; agent sees the result on its next turn via ui/update-model-context. No chat indirection.
  • SendMessage rail: Confirm fires SendMessage("Apply: call <tool>(...)") → agent recognizes the prefix and re-issues; iframe is replaced by whatever the apply's response card looks like. Always at least one agent round-trip; verbose chat lines on large applies; ~400 tokens of dual-rail coaching in every preview tool's description.

PR #807 then migrated drill-in / view-in-Katana / open-ended follow-up buttons (the other SendMessage uses across prefab_ui.py) to CallTool / OpenLink / UpdateContext. After #807 the only SendMessage instances left in the apply-card surface were _build_apply_action (Confirm) and _build_cancel_action (Cancel) — the rail this ADR finishes migrating.

Decision

The Confirm and Cancel buttons on every preview card fire deterministic actions via the same rail:

  • Confirm button fires:
  • SetState("pending", True) — disables both buttons immediately (in-flight click guard against double-fire).
  • CallTool(<apply_tool>, arguments={<original args>, preview: False}, on_success=[…], on_error=[…]) — calls the apply tool from the iframe with preview=False.
    • on_success runs SetState("result", $result), SetState("applied", True), and UpdateContext(content=$result) — the third action pushes the structured result into the agent's model context for its next turn.
    • on_error runs SetState("error", $error), a ShowToast, and UpdateContext(content="Apply failed: $error") — the agent sees the failure reason.
  • Cancel button fires:
  • SetState("cancelled", True) — flips the card to a "Cancelled" pill, locks both buttons.
  • UpdateContext(content="User cancelled <description> preview.") — the agent's context picks up the opt-out without a chat line. <description> is a noun phrase that already carries its own determiner (e.g. "the stock adjustment", "that purchase order", "those purchase order changes"); the template does not add a leading article, so call sites must supply one to read naturally.

_build_apply_action and _build_apply_action_direct collapse into a single _build_apply_action function. _render_apply_button_row loses its direct_apply flag — the state machine is the same for every tool (Preview → Pending → Applied/Error/Cancelled). register_preview_tool and with_preview_coaching lose their direct= flag and ship one coaching variant; the PREVIEW_APPLY_DIRECT_COACHING constant goes away.

The _build_apply_message helper (which produced the Apply: call <tool>(<args>, preview=False) chat-string format) is deleted — no longer reachable.

Consequences

Positive Consequences

  • Architectural symmetry. The apply rail and the rest of prefab_ui (post-#807) now use the same primitives: CallTool for deterministic re-invocation, UpdateContext for context signals, OpenLink for navigation. The mental model is uniform.
  • One fewer round-trip per apply. ADR-0015 explicitly noted "one extra LLM round-trip per apply, ~2–5s and a few hundred tokens" as the cost of the SendMessage rail. Removed.
  • No more dual-rail tax in tool descriptions. Every preview-mode write tool's description was carrying ~400 tokens of dual-rail coaching (one branch for SendMessage, one for direct-apply). Single coaching variant now.
  • No Apply: call <tool>(<args>...) chat lines. Verbose, parseable-but-ugly. Replaced by an UpdateContext notification that doesn't pollute the chat history.
  • Determinism. The iframe builds the apply args at preview-build time and bakes preview=False into the CallTool.arguments dict. There is no agent transcription step to drop a field or re-format a value (#491 territory).
  • Same chosen Confirm/Cancel UX. Button labels still say "Confirm X" / "Cancel"; the user-visible flow is identical. Only the underlying action mechanism changed.

Negative Consequences

  • Cross-host coverage tied to MCP Apps support. The direct-apply rail requires ui/update-model-context (SEP-1865, 2026-01-26). Hosts that render iframes but not MCP Apps would not deliver the apply result back to the agent. The set of hosts in that category appears to be empty in practice (Claude Desktop, Claude.ai, Cowork all support MCP Apps), but the surface is narrower than ADR-0015's "works on every host that renders Prefab cards" guarantee. The no-iframe fallback (host-doesn't-render-iframes path in the coaching) handles CLI clients the same way as before.
  • One agent-coaching block to maintain instead of two. Removed, not just merged — with_preview_coaching and register_preview_tool shrunk.

Neutral Consequences

  • destructiveHint policy unchanged. ADR-0015's per-tool destructiveHint table (delete/modify True, create False, etc.) carries forward; this ADR doesn't touch annotation policy.
  • The _render_apply_button_row state machine is unchanged in behavior — Pending → Applied → Cancelled transitions worked the same way on both rails, and the SendMessage rail's "no applied/error states" was a degenerate case of the same machine.

Alternatives Considered

Alternative 1: Keep both rails, document the split

  • Description. Leave _build_apply_action (SendMessage) and _build_apply_action_direct (CallTool+UpdateContext) as separate functions, with direct=True opt-in per tool. The dual-rail tool descriptions stay.
  • Why rejected. No remaining reason for the split. The SendMessage rail was a workaround for a now-resolved spec gap. PR #807 already collapsed the other SendMessage uses in prefab_ui; leaving the apply rail behind perpetuates inconsistency.

Alternative 2: Restore context.elicit()

  • Description. Replace iframe Confirm/Cancel with server-initiated elicitation dialogs.
  • Why rejected. Same reason as ADR-0015's Alternative 1: most candidate hosts don't support elicitation; the spec restricts schema to flat primitives; it'd raise on unsupported hosts.

Alternative 3: Drop the iframe rail entirely; chat-only "yes"

  • Description. No buttons; preview ends the turn, user types "yes" to apply, agent re-issues.
  • Why rejected. Same reason as ADR-0015's Alternative 3: works, but loses the one-click affordance. The unified direct-apply rail delivers the same fidelity and keeps the button.

References

  • ADR-0015 (superseded) — original SendMessage-rail rationale + the destructiveHint policy table that carries forward
  • PR #807SendMessage → CallTool/OpenLink/UpdateContext migration for drill-in / view-in-Katana / open-ended buttons (13 CallTool, 5 OpenLink, 21 UpdateContext sites; precedent for this ADR)
  • MCP Apps spec, SEP-1865ui/update-model-context notification (2026-01-26)
  • MCP Tools spec 2025-11-25
  • 316 — confirmation-pattern decision (the parent thread for ADR-0015)

  • 491 — Confirm-button arguments dropped (closed by #493) — the template-binding

    fragility the direct rail also avoids by inlining args into CallTool.arguments at card-build time