Skip to content

Latest commit

 

History

History
277 lines (232 loc) · 15.5 KB

File metadata and controls

277 lines (232 loc) · 15.5 KB

Artifacts — revive PR #263 as a first-party capability

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.

v1 scope (Rhys, 2026-07-27)

  • Model-generated only. The #263 flow: the MCP execute tool 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.ui so 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.

What to salvage from #263 (the ~3.5k LoC shell is plugin-free inside)

  • 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 via prefers-color-scheme + host theme; reload-restore via ontoolinput.
  • 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-ui tool and its description (the PR body is stale — it describes an early isReactCode-detection-inside-execute design; 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.

What to kill from #263 (the plugin costume)

  • The packages/plugins/dynamic-ui packaging itself.
  • packages/core/vite-plugin changes (Phase 2.1 deletes that package).
  • The plugin route (apps/cloud/.../plugins.$pluginId.$.tsx addition).
  • 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.

Data model

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.

MCP surface

  • render-ui stays a dedicated tool (the branch's final shape; the stale PR body describes JSX detection inside execute — do not resurrect that). execute remains the discovery/data path per the tool description's discovery-vs-render protocol. render-ui persists the artifact on successful render (model supplies title/description via the tool contract).
  • Capability fallback (Rhys, 2026-07-27): render-ui is 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.ui embedded 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/:slug links).

Sharing portability: connection names in tool paths (explored 2026-07-28)

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. pins subject = "", tenant-visible — same rows for both users);
  • breaks per-call on any .user. path: the row lookup pins B's subject, so B gets tool_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.

Trust note

#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.

Sequencing

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.

Iteration and create-time render checking (shipped)

Two additions after the v1 surface landed, both about the same gap: the model never found out when an artifact was wrong.

Updating an artifact in place

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.

Catching first-render failures at create time

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:

  1. Provider pairing (PROVIDER_PAIRINGS in create-artifact.ts) — a table of names that oblige a wrapper. Only the chart family qualifies today: of the four components in packages/react that throw on a missing context (useChart, useCarousel, useFormField, useSidebar), only Chart* is exported from the shell's barrel. Tooltip needs a TooltipProvider but the shell pre-provides one, so requiring it would refuse working code. The artifact-style skill's chart recipe now shows ChartContainer as the mandatory wrapper, since the model wrote the broken code because the recipe did not.
  2. A real render (smoke-render.ts in the shell package) — the component is compiled and rendered once server-side through the same compileJsx/evaluateComponent/createToolsProxy the iframe uses, with the same provider stack, and a tools transport 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. evaluateComponent uses new Function, which Cloudflare refuses outright — so the runtime is probed once and the check reports ok for 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/local is also left out, because its consumer apps/cli cannot resolve the renderer's .tsx graph without jsx configured.

Approving an artifact action (fixed)

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.

Why it failed

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.

The fix

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.

Follow-ups

  • 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.

Opt-in

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.