Skip to content

UI contributions

Integration developers can build a full native React home page, or supply a worker-rendered page using the extension kit. Native pages own their content, layout, forms, and settings. The common host keeps navigation, plan access, and uninstall consistent.

Worker bundles contribute UI by returning component specs — plain JSON like { type: "Pill", props: { … } } — from slot renderers. The host validates every tree and renders it with first-party markup, so contributions inherit the product’s typography, spacing, and theming automatically. Worker contributions use the kit’s tones and layouts.

Slot id Called with Must return
photo-tile.badge { workspaceId, projectId, assetIds[], api } — once per photo grid A map of assetId → Badge | ProgressRing | null
project.header.status { workspaceId, projectId, api } Pill or null
dashboard.card { workspaceId, api } Card or null
integration.page { workspaceId, canConfigure, api } Any validated kit component tree, or null
activity.feed — (no renderer) Declared in the catalog; rows are emitted by your sync, not rendered by the bundle. See Activity events.

Root component types are enforced per slot — a Card returned to the pill slot is a validation failure for that render, not a broken page.

Each installed integration has a page under Integrations → My integrations. The host provides the integration identity, install status, store link, and permission-controlled uninstall action. The body can use either of the following developer page options.

Trusted first-party integrations register a React component in apps/web-next/src/components/integrations/IntegrationManagementContent.tsx. That module exports INTEGRATION_MANAGEMENT_PAGES and IntegrationManagementPageProps. Each app’s component controls its full page body: sections, tabs, forms, settings, and other product components. The host keeps the integration identity, access notices, navigation, and uninstall action around it.

For example, a native Promotions home can compose its existing editable campaign settings with its own layout:

import type { ComponentType } from "react";
import { PromotionsClient } from "@/app/org/[namespace]/(authed)/settings/promotions/PromotionsClient";
function CustomPromotionsHome({
canConfigure, canReadSettings,
}: IntegrationManagementPageProps) {
return (
<div className="space-y-6">
<header>
<h2 className="text-xl font-semibold">Campaign workspace</h2>
<p className="text-sm text-muted-foreground">
Manage offers and your public pricing calculator.
</p>
</header>
{canReadSettings ? (
<PromotionsClient embedded readOnly={!canConfigure} />
) : (
<p>Ask a workspace administrator to view campaign settings.</p>
)}
</div>
);
}
// In IntegrationManagementContent.tsx, preserve the other app mappings:
export const INTEGRATION_MANAGEMENT_PAGES:
Readonly<Record<string, ComponentType<IntegrationManagementPageProps>>> = {
// ...other integration pages
promotions: CustomPromotionsHome,
};

For another integration, add its catalog entry, create its React page, and add an app-id mapping to this registry. The generic integrations/installed/[appId] route does not need app-specific changes. Leave integration.page out of the manifest when using a native page; an explicit worker page declaration takes precedence over the registry.

The props include the current listing, plan eligibility, configuration and settings-read access, and onChanged. Use those values to decide which settings to show, and call onChanged() after a successful change to refresh installed state. Server endpoints still enforce the plan and permissions. Native pages can keep saved information readable while hiding configuration controls from free or unauthorized members.

A bundled extension declares integration.page in the integration’s catalog manifest and registers a renderer for it:

// In your registered IntegrationApp catalog entry:
contributions: {
bundle: "stageory-first-party://my-integration/extension.js",
slots: ["integration.page"],
extensionApiVersion: 1,
}
import {
Card, Stack, Text, register, startExtensionRuntime,
type IntegrationPageContext,
} from "@homestage/extension-kit";
register("integration.page", async (ctx: IntegrationPageContext) =>
Stack({ gap: "md" }, [
Card({ title: "Overview" }, [
Text({ text: "Your integration is installed in this workspace." }),
Text({
text: ctx.canConfigure
? "You have permission to configure this integration."
: "Ask a workspace administrator to change its configuration.",
variant: "caption",
}),
]),
]),
);
startExtensionRuntime(self);

The canonical URL is /org/:namespace/integrations/installed/:appId. The page appears in the sidebar after installation, including installations that still need setup. An integration must be installed and have an eligible plan or active trial before its extension body runs. Members without configuration permission receive canConfigure: false. Free and expired workspaces can still open the host’s installed-integration page, inspect retained install status, and uninstall when their role permits it.

canConfigure is a presentation hint, not an authorization mechanism. Every server endpoint must check the member’s permissions and workspace plan independently. Uninstall stays in the host so an extension cannot hide or replace that action.

This is a component-spec page, with the same bounds as other slots. It does not accept React, HTML, CSS, arbitrary URLs, or embedded frames. The current kit contains presentation components and buttons; it has no text inputs, selects, forms, navigation API, or write bridge. api.get can only call existing endpoints under /v1/integrations/:appId/. Declaring a page does not create those endpoints. A custom configuration form therefore needs a native first-party page or a separately reviewed extension-kit and server capability addition.

Bundle resolution currently supports registered stageory-first-party://... bundles served by Stageory. Declaring an external HTTPS bundle does not enable community loading. The built-in integrations use native home components unless their manifest explicitly declares integration.page.

All constructors come from @homestage/extension-kit and return ComponentSpec JSON.

Badge({ shape: "dot" | "count", tone, tooltip?, count? })
ProgressRing({ value: /* 0–1, or null = indeterminate */, tone, tooltip? })
Pill({ text, tone, icon? })
Card({ title }, children)
Row({ gap?: "sm" | "md" }, children)
Stack({ gap?: "sm" | "md" }, children)
Text({ text, variant?: "body" | "caption", truncate? })
Icon({ name })
Button({ label, onPress })

"neutral" | "success" | "pending" | "warning" | "danger" — mapped to theme-consistent colors on the host side, correct in light and dark mode. This is the only way a contribution expresses color.

A fixed nine-name set, rendered from the product’s own icon system:

images · calendar · folder · plug · send · users · receipt · megaphone · chart

Unknown names fall back to plug rather than failing the render.

onPress never crosses the worker boundary. The spec carries only a generated handler id; when the customer clicks, the host routes the event back into your worker and your callback runs there. A callback that throws is contained — it cannot crash in-flight renders.

ctx.api.get(path) is your only network access. It resolves page-side with the customer’s session, against an allowlist scoped to your app — your worker never sees a token, a cookie, or an unlisted endpoint. ctx.api.theme.mode ("light" | "dark") is sampled fresh for every render.

Limit Value On overflow
Render soft budget 100 ms logged warning
Render hard budget 1 000 ms worker terminated + restarted, render fails
Nodes per slot tree 200 that render fails
Tree depth 8 that render fails
Response size 64 KB that render fails
Crash strikes 3 per 10 min UI disabled for the session

Compact slots render failures as a small muted glyph with a tooltip. Integration pages show a skeleton while loading and an inline retry message on failure; a crash-loop-disabled page asks the member to reload. The host’s page controls remain available.