Decision (Rhys, 2026-07-27): revive the generative UI shell from PR #263
("Add generative UI shell for interactive React components", branch
claude/generative-ui-mcp-apps-BG4vy, last pushed 2026-06-30) as a
persistent artifacts feature — but re-lifted first-party, not rebased.
The PR was built as packages/plugins/dynamic-ui; every plugin touchpoint it
added is on the Phase 2 kill list in kill-plugin-system.md,
so reviving it via rebase would port code onto infrastructure we're deleting.
- Model-generated only. The #263 flow: the MCP
executetool detects JSX in a result and renders it via MCP Apps. No hand-authoring, no deploy/graduation story yet (that's the vision.md "authored artifact" tier — later, possibly on the Phase 3 concept-package model). - Persistent + personal. Generated artifacts are saved with a name/title, owned by the generating user. The sidebar gets an Artifacts tab listing them (open/re-render, rename, delete).
- Retrievable through MCP. "Show me my active users dashboard" must work
from any MCP client: the agent lists saved artifacts, matches by
title/description, and a render tool returns the stored JSX with
_meta.uiso the client renders it as an MCP App. Same shell, same delivery path as first generation. - No sharing at launch. Org-share comes later, but it is just the existing
persisted
owner: "org" | "user"tier + visibility-is-union scope merging — so the table carries the owner tier column from day one (rows all user-owned at launch), and no new sharing machinery is ever needed.
- Shell runtime: Sucrase JSX compile + sandboxed eval; React, shadcn/ui,
Recharts, Lucide in scope; runtime Tailwind (
@tailwindcss/browser); error boundary; JSON view for non-JSX results; configurable maxHeight; auto-resize; dark mode viaprefers-color-scheme+ host theme; reload-restore viaontoolinput. - Tools proxy: tRPC-style recursive proxy (
tools.github.issues.create()→execute-action), kernel envelope unwrap,run()escape hatch. - The browser test suite (
mcp-app.browser.test.ts, ~1.4k lines) and the sunpeak MCP Apps e2e harness (e2e/mcp-apps/). - The dedicated
render-uitool and its description (the PR body is stale — it describes an earlyisReactCode-detection-inside-executedesign; the branch's final shape is a separate tool whose description enforces the discovery-vs-render split and JSX guardrails, e.g. no hardcoded live-data arrays). Keep the separate-tool shape.
- The
packages/plugins/dynamic-uipackaging itself. packages/core/vite-pluginchanges (Phase 2.1 deletes that package).- The plugin route (
apps/cloud/.../plugins.$pluginId.$.tsxaddition). - The new plugin seam in
packages/hosts/mcp(plugin.ts). - Per-host feature-flag files and the plugin wiring in
apps/local/main.ts.
New homes: shell + renderer in a first-party package (or packages/react);
MCP host exposes the ui-bearing tools directly; the Artifacts tab is an
ordinary route in packages/app. No feature-flag-per-host, no virtual
modules, no plugin registration.
Core artifact table following the subject-table pattern (Phase 1.1 of the
plugin-kill plan): tenant-scoped coreTables entry + cloud drizzle mirror +
one migration. Columns (roughly): id, tenant, owner tier, subject, title,
description (for agent matching), jsx source, created_at, updated_at.
Immutable content-hashed versions (rollback = pointer move, per the Phase 3
model) are a later upgrade — v1 can overwrite in place.
render-uistays a dedicated tool (the branch's final shape; the stale PR body describes JSX detection insideexecute— do not resurrect that).executeremains the discovery/data path per the tool description's discovery-vs-render protocol.render-uipersists the artifact on successful render (model supplies title/description via the tool contract).- Capability fallback (Rhys, 2026-07-27):
render-uiis always advertised, same description, regardless of client MCP Apps support. When the client doesn't advertise MCP Apps, the tool still persists the artifact but returns a deep link into the product (the artifact's page in the web app) instead of the_meta.uiembedded resource. This is vision.md's delivery-negotiation rule verbatim: "a UI → an embedded UI resource if the client renders it (MCP apps), else a deep link into the web app." The model behaves identically either way; only delivery changes. Persistence is what makes the fallback possible — the deep link needs a durable row to point at. - New tools: list artifacts (title + description, for matching), render artifact by id — same dual delivery (embedded resource or deep link). Delete/rename can stay web-UI-only in v1.
- Deep-link shape: stable
/artifacts/:id-style URL (auth-aware redirect, same pattern as the 1.6/connect/:sluglinks).
Codemode addresses are 5 fixed segments —
tools.<integration>.<org|user>.<connectionName>.<tool> (parseToolAddress,
packages/core/sdk/src/executor.ts:209) — and the connection segment is the
raw connection.name, resolved by exact equality with no fallback or
aliasing. Names are user-derived (label → connectionIdentifier camelCase;
default personal<Integration> / workspace<Integration> — "main" is a test
fixture, not a convention). So a shared artifact from user A:
- works unmodified for B only when every path uses an org-tier connection
(
.org.pinssubject = "", tenant-visible — same rows for both users); - breaks per-call on any
.user.path: the row lookup pins B's subject, so B getstool_not_found(empty suggestions — the suggestion query itself filters on the unresolvable connection segment), surfaced as a query error state inside the artifact. Even an identically-named user-tier connection of B's own doesn't match today.
The custom-tools (apps) plugin already models the fix (dependency
injection, packages/plugins/apps/src/plugin/bindings.ts): a tool declares
integrations as roles (field → IntegrationDecl), never a literal
connection; the connection arrives at invoke time and
resolveIntegrationBindings resolves it against the caller's own connections
(with a candidate list in the error when it can't). The artifact analogue:
extract the integration roles an artifact uses (derivable from its tool
paths), store them as declared dependencies, and bind role → viewer's
connection at render time — swap the account or pass it in, exactly the
custom-tools shape. Cheapest hook points if/when built:
tool-invoker.ts:305 (pathToAddress — every sandbox path passes through,
already inside the viewer's scoped executor) or the shell's toolPathToCode
(shell-app.tsx:112) if the binding should be user-visible/overridable in
the UI. Deliberately NOT in v1 scope (v1 artifacts are viewer-owned, so
paths always resolve); this lands with sharing.
#263 auto-approves elicitations for UI-initiated execute-action calls
(the user consented by interacting). Acceptable for v1 because every artifact
is the viewer's own, model-generated in their session. Must be revisited
before sharing ships — an org-shared artifact executing tools with the
viewer's connections on click is a different trust story.
Independent of the plugin-kill phases: built plugin-free from the start, it touches none of the seams Phase 2 deletes, and avoids re-creating dynamic-ui as an eighth plugin. The persistence PR follows the same schema conventions as the in-flight subject-table stack (#1463+) but has no hard dependency on it.
Two additions after the v1 surface landed, both about the same gap: the model never found out when an artifact was wrong.
create-artifact takes an optional artifactId. Present, it overwrites that
artifact rather than minting a new one — the workflow a model actually has when
a user asks for a tweak is "show-artifact, edit the source, send it back", so
a separate update-artifact tool would take the same arguments and differ only
in whether a row is created. One tool keeps the catalog at three.
Semantics: code fully replaces the stored source (v1 keeps no version
history); title and description are optional on an update and absent means
keep the stored value; bindings are re-extracted and re-resolved from the NEW
code plus an optional connections map, because the roles the new source uses
are not necessarily the old ones; an id that is not the caller's is refused with
the same artifact_unavailable the binding path uses, so create-artifact cannot
be used to probe which ids exist. The artifact keeps its id, so a link already
in the user's hands resolves to the new version. The detail page re-reads its
row when the tab regains focus, which is when a user who asked an agent for a
change looks back at it.
The motivating bug: a model wrote <ChartTooltipContent /> with no
<ChartContainer>, the artifact saved cleanly, and the page died with useChart must be used within a <ChartContainer />. The model got a success back and had
no idea. Two layers now run before a save:
- Provider pairing (
PROVIDER_PAIRINGSincreate-artifact.ts) — a table of names that oblige a wrapper. Only the chart family qualifies today: of the four components inpackages/reactthat throw on a missing context (useChart,useCarousel,useFormField,useSidebar), onlyChart*is exported from the shell's barrel.Tooltipneeds aTooltipProviderbut the shell pre-provides one, so requiring it would refuse working code. Theartifact-styleskill's chart recipe now showsChartContaineras the mandatory wrapper, since the model wrote the broken code because the recipe did not. - A real render (
smoke-render.tsin the shell package) — the component is compiled and rendered once server-side through the samecompileJsx/evaluateComponent/createToolsProxythe iframe uses, with the same provider stack, and atoolstransport whose promises never settle so every query stays pending. A synchronous first-render throw is refused with the real message plus React's component stack.
Non-goal: data-shape errors. Code that only throws once a tool returns an unexpected payload is out of scope. Nothing is fetched at create time, and inventing a plausible payload would reject correct code whenever the guess was wrong. That class stays a runtime error inside the frame, where the shell's error boundary already reports it. Undefined query data during loading is NOT in this category — it is a real first-render crash and is caught.
Two constraints shaped the implementation:
- Fail open, always. The renderer is injected through a config seam, and if it breaks the artifact still saves. Refusing a valid create because our own check broke is the worse failure, and it is pinned by test.
- workerd cannot evaluate.
evaluateComponentusesnew Function, which Cloudflare refuses outright — so the runtime is probed once and the check reportsokfor everything where it cannot run. Cloud therefore gets the static checks only; self-host and the HTTP hosts get the render. (Executor already runs model code on workerd through QuickJS for the same reason; putting React inside that sandbox is a much larger change than this is worth.)apps/localis also left out, because its consumerapps/clicannot resolve the renderer's.tsxgraph withoutjsxconfigured.
Clicking a destructive action inside an artifact on cloud failed the approval
with ExecutionNotFoundError. The reported symptom pointed at an expiring
approval window, but the trigger was not time: a resume issued 7ms after the
pause fails exactly the same way.
The console's artifact page has no MCP client, so the shell's host adapter
(packages/react/src/api/shell-host.ts) reaches the server over the ordinary
executions HTTP API — POST /executions, then POST /executions/:id/resume.
On cloud those paths are served by the stateless worker, not by a Durable
Object: makeExecutionStackMiddleware builds a fresh executor + engine for every
request, and a paused execution lives only in that engine instance's in-memory
pausedExecutions map. The pause is therefore discarded with the request that
created it, and the resume lands on a new engine whose map is empty. null from
engine.resume became ExecutionNotFoundError, surfaced through the shell's
postMessage bridge as a JSON-RPC -32603.
The MCP plane never had this problem because its engine lives for the lifetime of
the session Durable Object, which is also why the #1300 cross-session work
(McpExecutionOwnerDirectoryDO, resumeFallback) is wired only into the MCP
resume path. That machinery routes a resume to the DO holding the pause; the
HTTP route carries no session id, so there was never a DO for it to find.
An artifact action is not arbitrary code. The shell's grammar is exactly one
tool call, and the approval gate (enforceApproval) runs strictly before the
tool is invoked — so a paused artifact action holds no partial work, only a
resolved intent that has not happened yet. That makes the pause reconstructible
rather than something that must be kept alive.
POST /executions with an artifactId now records the resolved call as a
durable pending approval (owner-scoped, in the existing blob table, so no
migration and no new table), keyed by execution id. On resume, the handler first
tries the in-memory engine (unchanged, and still the path on local/desktop and
for any same-instance resume), and falls back to the durable record: it verifies
the record belongs to this caller, then runs the already-resolved call with the
approval applied. Records carry an expiry and are consumed on use, so an approval
is single-use and a decline drops it without running anything.
This is deliberately scoped to artifact-originated executions. General codemode
execute can pause anywhere inside arbitrary code, so its pause genuinely is a
suspended fiber and cannot be reconstructed this way; that path keeps its
existing behavior.
When resume finds neither a live pause nor a durable record, the shell now renders an explicit expired-approval state ("trigger the action again") instead of surfacing a raw MCP error.
- Preference management UI. "Approve and don't ask again" grants are stored client-side, per browser profile, keyed by artifact + tool address. There is no UI yet to review or revoke them; clearing site data is the reset. A grants list (view/clear) is the follow-up.
- Sharing will revisit both halves: a shared artifact executing tools with the viewer's connections needs the trust story in "Trust note" settled first, and don't-ask-again grants must not survive a change of artifact owner.
Artifacts are opt-in per MCP connection: ?artifacts=true on the MCP endpoint
(--artifacts on the stdio CLI) serves the artifact tools, the ui:// shell
resource, and the artifact skills for that session; without it a connection
gets none of them. Off by default; the install card's Advanced section carries
the toggle. The console's Artifacts pages are always on regardless.