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 viaui/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 withpreview=False.on_successrunsSetState("result", $result),SetState("applied", True), andUpdateContext(content=$result)— the third action pushes the structured result into the agent's model context for its next turn.on_errorrunsSetState("error", $error), aShowToast, andUpdateContext(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:CallToolfor deterministic re-invocation,UpdateContextfor context signals,OpenLinkfor 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 anUpdateContextnotification that doesn't pollute the chat history. - Determinism. The iframe builds the apply args at preview-build time and bakes
preview=Falseinto theCallTool.argumentsdict. 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_coachingandregister_preview_toolshrunk.
Neutral Consequences¶
destructiveHintpolicy unchanged. ADR-0015's per-tooldestructiveHinttable (delete/modify True, create False, etc.) carries forward; this ADR doesn't touch annotation policy.- The
_render_apply_button_rowstate 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, withdirect=Trueopt-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
destructiveHintpolicy table that carries forward - PR #807 —
SendMessage → CallTool/OpenLink/UpdateContextmigration for drill-in / view-in-Katana / open-ended buttons (13 CallTool, 5 OpenLink, 21 UpdateContext sites; precedent for this ADR) - MCP Apps spec, SEP-1865
—
ui/update-model-contextnotification (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 intoCallTool.argumentsat card-build time