Skip to main content

Workspace Composition 10-Minute Developer Happy Path

Final App Usage

This is the current Claude workspace product-kit wiring path. ClaudeWorkspace and ClaudeWorkspaceRenderer React facades are still deferred; today the host uses createClaudeWorkspaceRuntime(...) and renders the resulting WorkspaceComposition through AssistantWorkspace.

v0 Workspace-Private Example

This guide is runnable from a repository checkout using workspace:* dependencies. It is not an external npm install guide for @maya/claude-workspace or @maya/data-lab-module.

External consumers should install only the public packages: @maya/assistant-composition, @maya/assistant-ui, and @maya/assistant-protocol. Use this guide to understand the explicit composition/render-runtime shape.

The two host-supplied inputs below are workspaceControllers and hostClaudeRenderPacks. workspaceControllers is the existing host-owned grouped controller object for Claude workspace data and callbacks. A Claude host must also provide render packs for its own built-in Claude routes and surfaces. @maya/claude-workspace does not export those built-in React render packs yet, so do not pass [] unless your explicit composition policy hides descriptor-level built-in contributions or replaces descriptor ownership before those contributions require handles. v0 still does not publish built-in page replacement, DOM patching, or page-internal slot APIs. The smoke snippet later in this guide shows a test-only host render pack for coverage checks.

From a clean repository checkout, validate this guide with:

pnpm install
pnpm build:packages
pnpm smoke:consumer
import '@maya/assistant-ui/styles.css';
import '@maya/data-lab-module/styles.css';

import { AssistantWorkspace, type AssistantWorkspaceGroupedProps } from '@maya/assistant-ui/workspace';
import type { ReactRenderPack } from '@maya/assistant-ui/render-runtime';
import { createClaudeWorkspaceRuntime } from '@maya/claude-workspace';
import {
bindDataLabModuleRuntime,
createDataLabModule,
defineDataLabRuntime,
} from '@maya/data-lab-module';
import {
createDataLabRenderPack,
type DataLabController,
} from '@maya/data-lab-module/render-pack';

const dataLabInstance = {
instanceId: 'data-lab-prod',
label: 'Data Lab',
routeBasePath: '/data-lab',
toolClaim: 'tool:data_lab.query',
} as const;

const declaration = createDataLabModule(dataLabInstance);

const runtime = defineDataLabRuntime({
instanceId: dataLabInstance.instanceId,
status: 'ready',
grants: ['data-lab.query'],
missing: [],
actions: [],
});

const controller = {
instanceId: dataLabInstance.instanceId,
label: dataLabInstance.label,
connection: {
baseUrlLabel: 'host-owned.example',
apiKeyConfigured: true,
health: 'ready',
},
metrics: {
datasetCount: 3,
lastRefreshLabel: 'just now',
},
actions: {
runQuery: async (input) => ({
title: input.query,
summary: `Host executed ${input.query}`,
rows: [],
}),
},
} satisfies DataLabController;

const resolvedModule = bindDataLabModuleRuntime(declaration, runtime);
const dataLabRenderPack = createDataLabRenderPack({
instanceId: dataLabInstance.instanceId,
controller,
});

export function App({
workspaceControllers,
hostClaudeRenderPacks,
}: {
workspaceControllers: AssistantWorkspaceGroupedProps;
hostClaudeRenderPacks: readonly ReactRenderPack<any>[];
}) {
const workspaceRuntime = createClaudeWorkspaceRuntime({
modules: [resolvedModule],
renderPacks: [...hostClaudeRenderPacks, dataLabRenderPack],
});

if (!workspaceRuntime.ok || !workspaceRuntime.composition) {
throw new Error(workspaceRuntime.diagnostics.map((diagnostic) => diagnostic.message).join('\n'));
}

return (
<section className="aui-root" data-theme="system" data-resolved-theme="light">
<AssistantWorkspace
{...workspaceControllers}
activeRoutePath={dataLabInstance.routeBasePath}
composition={workspaceRuntime.composition}
renderRuntime={workspaceRuntime.renderRuntime}
/>
</section>
);
}

For a repository workspace host, add the packages as workspace dependencies. External npm publishability for @maya/claude-workspace and @maya/data-lab-module is not opened yet, so this guide uses documented workspace package exports without claiming those private packages are already publishable artifacts.

pnpm --filter your-host add @maya/assistant-composition@workspace:* @maya/assistant-ui@workspace:* @maya/claude-workspace@workspace:* @maya/data-lab-module@workspace:*

Choose An instanceId

instanceId is the enabled module instance, not the package name. Use a stable deployment, tenant, or data-source name such as data-lab-prod, and pass the same value to createDataLabModule, defineDataLabRuntime, the host-owned DataLabController, and createDataLabRenderPack.

For multi-instance usage, give each instance a distinct route path and renderer claim:

const prod = createDataLabModule({
instanceId: 'data-lab-prod',
routeBasePath: '/data-lab',
toolClaim: 'tool:data_lab.query',
});

const staging = createDataLabModule({
instanceId: 'data-lab-staging',
routeBasePath: '/data-lab-staging',
toolClaim: 'tool:data_lab.query.staging',
});

What The Host Owns

The module is a trusted React UI adapter. The host owns runtime resolution, deployment capability checks, user policy, backend authorization, API keys, base URLs, health checks, network requests, storage, audit behavior, and persistent configuration.

grants are UI activation contracts, not security boundaries. They tell the module which UI surfaces to enable after the host has already resolved policy; they do not authorize backend work by themselves.

Missing Render Pack Troubleshooting

If createClaudeWorkspaceRuntime(...) returns ok: false with compositionOk: true, inspect workspaceRuntime.requiredHandleCoverage. Missing render packs appear as WCP-HANDLE-001 diagnostics.

Fix missing coverage by checking these exact points:

  • The host explicitly imports the module render pack from @maya/data-lab-module/render-pack.
  • The host imports @maya/data-lab-module/styles.css under the same .aui-root token scope as @maya/assistant-ui/styles.css.
  • createDataLabRenderPack({ instanceId, controller }) uses the same instanceId as the module declaration and host runtime.
  • renderPacks includes the module render pack and any host-provided Claude built-in render packs.
  • The host does not rely on global render registration, package auto-discovery, remote ESM, iframe loading, or runtime installs.

Runnable Smoke Path

This smoke code exercises the two composition paths that v0 supports today:

  • Data Lab-only module composition through composeWorkspace(...) and createReactRenderRuntime(...).
  • Claude product-kit composition through createClaudeWorkspaceRuntime(...) with an explicit test-only host render pack for Claude built-in handles.

The createHostClaudeRenderPackForSmoke() helper is only a coverage stand-in. It proves composition and render-handle coverage, not production Claude render packs or visual parity. Production hosts should replace it with real built-in Claude render packs owned by the host or by a later @maya/claude-workspace render-pack export.

import { composeWorkspace, type RenderHandle } from '@maya/assistant-composition';
import {
createReactRenderRuntime,
type ReactRenderComponent,
type ReactRenderPack,
} from '@maya/assistant-ui/render-runtime';
import {
claudeWorkspaceBuiltinOwner,
claudeWorkspaceBuiltins,
createClaudeWorkspaceRuntime,
} from '@maya/claude-workspace';
import {
bindDataLabModuleRuntime,
createDataLabModule,
defineDataLabRuntime,
} from '@maya/data-lab-module';
import {
createDataLabRenderPack,
type DataLabController,
} from '@maya/data-lab-module/render-pack';

const dataLabInstance = {
instanceId: 'data-lab-prod',
label: 'Data Lab',
routeBasePath: '/data-lab',
toolClaim: 'tool:data_lab.query',
} as const;

const declaration = createDataLabModule(dataLabInstance);
const runtime = defineDataLabRuntime({
instanceId: dataLabInstance.instanceId,
status: 'ready',
grants: ['data-lab.query'],
missing: [],
actions: [],
});

const controller = {
instanceId: dataLabInstance.instanceId,
label: dataLabInstance.label,
connection: {
baseUrlLabel: 'host-owned.example',
apiKeyConfigured: true,
health: 'ready',
},
metrics: {
datasetCount: 3,
lastRefreshLabel: 'just now',
},
actions: {
runQuery: async (input) => ({
title: input.query,
summary: `Host executed ${input.query}`,
rows: [],
}),
},
} satisfies DataLabController;

const resolvedModule = bindDataLabModuleRuntime(declaration, runtime);
const dataLabRenderPack = createDataLabRenderPack({
instanceId: dataLabInstance.instanceId,
controller,
});

const dataLabOnlyComposition = composeWorkspace({ builtins: [], modules: [resolvedModule] });
if (!dataLabOnlyComposition.ok) {
throw new Error(dataLabOnlyComposition.diagnostics.map((diagnostic) => diagnostic.message).join('\n'));
}

const dataLabOnlyRenderRuntime = createReactRenderRuntime({ packs: [dataLabRenderPack] });
const dataLabOnlyCoverage = dataLabOnlyRenderRuntime.validateCoverage(dataLabOnlyComposition.composition);
if (!dataLabOnlyCoverage.ok) {
throw new Error(dataLabOnlyCoverage.diagnostics.map((diagnostic) => diagnostic.message).join('\n'));
}

const NullClaudeSurface: ReactRenderComponent<{ ownerId: string }> = () => null;

function createHostClaudeRenderPackForSmoke(): ReactRenderPack<{ ownerId: string }> {
const handles = collectRenderHandles(claudeWorkspaceBuiltins.contributions);
return {
renderRuntimeSchemaVersion: '0.1',
owner: claudeWorkspaceBuiltinOwner,
packageId: '@maya/host-claude-renderers',
controller: { ownerId: claudeWorkspaceBuiltinOwner.ownerId },
providers: handles.map((handle) => ({
handle,
load: { kind: 'eager', component: NullClaudeSurface },
})),
};
}

function collectRenderHandles(value: unknown): RenderHandle[] {
if (!value || typeof value !== 'object') return [];
if (isRenderHandle(value)) return [value];
if (Array.isArray(value)) return value.flatMap(collectRenderHandles);
return Object.values(value).flatMap(collectRenderHandles);
}

function isRenderHandle(value: object): value is RenderHandle {
return 'renderHandleVersion' in value && 'kind' in value && 'id' in value;
}

const workspaceRuntime = createClaudeWorkspaceRuntime({
modules: [resolvedModule],
renderPacks: [createHostClaudeRenderPackForSmoke(), dataLabRenderPack],
renderRuntimeOptions: { mode: 'test' },
});

if (!workspaceRuntime.ok || !workspaceRuntime.compositionOk || !workspaceRuntime.requiredHandleCoverage?.ok) {
throw new Error(workspaceRuntime.diagnostics.map((diagnostic) => diagnostic.message).join('\n'));
}

if (!workspaceRuntime.composition?.routes.some((route) => route.key === 'data-lab-prod:routes/home')) {
throw new Error('Data Lab route was not composed into the Claude workspace runtime');
}

Advanced APIs

  • .docs/packages/claude-workspace.md: current @maya/claude-workspace builder boundary and deferred React facade status.
  • .docs/packages/data-lab-module.md: concrete first-party module package, render-pack, CSS, fixture, and multi-instance example.
  • .docs/architecture/workspace-composition-module-authoring.md: canonical module authoring, contract, state, CSS, and conformance rules.
  • .docs/architecture/workspace-composition-kernel.md: composeWorkspace, namespace, ordering, policy, and conflict diagnostics.
  • .docs/architecture/workspace-composition-react-render-runtime.md: scoped ReactRenderRuntime, opaque handles, render packs, coverage, fallback, preload, and trust policy.

Not In This Guide

The canonical v0 known limitations live in .docs/release-governance.md. In short, this guide does not cover plugin marketplace, iframe sandbox, remote ESM, online dynamic install, runtime untrusted installs, import-time global registry, module federation, local patching of built-in pages, default user/assistant text renderer override, Claude React facades, Claude built-in render pack exports, or @maya/claude-workspace / @maya/data-lab-module npm publishability.