@collective-intelligence/extensions-sdk-frontend-react
Public, host-neutral contracts for React contributions rendered by Collective Intelligence Studio or Dashboard.
React and React DOM are host-provided peers. The optional
@collective-intelligence/ui-react peer is required when importing the
/workflow or /ui-react subpaths; declaration-only consumers do not need it.
effect (^4.1.0) is a required peer: the app installs one copy of effect,
and the package build keeps it external. The /effect entrypoint exports the
StudioApi service; see Studio API on Effect. It
also exports fromEffect, EffectRuntimeProvider, useEffectRuntime and
QueryCache, the parts of a write in a Studio Widget; see
Writes in a Studio Widget. They need the
required xstate peer, which the Studio and Dashboard Hosts provide.
import { declareFrontendCatalogContribution } from "@collective-intelligence/extensions-sdk-frontend"
import { declareStudioComponent } from "@collective-intelligence/extensions-sdk-frontend-react/studio"
import { customerPageProps } from "./schemas"
const sourceRef = "com.acme.crm.studio.customer-view"
export const studioCatalog = declareFrontendCatalogContribution({
fqn: sourceRef,
host: "studio",
load: () => import("./studio-catalog")
})
export const customerPage = declareStudioComponent({
definition: {
category: "page",
propsSchema: customerPageProps,
sourceRef
},
manifest: {
fqn: "com.acme.crm.studio.customer-page",
version: "1.0.0"
},
presentation: { displayName: "Customer" }
})
Declarations are data: they do not register with Studio or Dashboard and do
not own module delivery, host resolution, or error-boundary rendering. Catalog
Hosted Contributions come from the React-free frontend SDK and can be passed to
declarePackage({ hostedContributions: [...] }); their lazy load functions
never enter the serialized package manifest. This React package intentionally
does not expose the retired leaf declareStudioView or
declareDashboardWorkflowField helpers.
sourceRef is required on every Studio component render leaf and remains in
the serialized package manifest. It identifies the same package's hosted
Studio contribution; it is not a source path or an alternate runtime URL.
Frontend runtime
The frontend entrypoint provides the shared React execution boundary for
declarations from @collective-intelligence/extensions-sdk-frontend:
import {
createHostedDiagnostic,
HostedExtensionRender,
loadDeclarationModule
} from "@collective-intelligence/extensions-sdk-frontend-react/frontend"
;<HostedExtensionRender
declaration={declaration}
componentProps={{ customerId: "customer-1" }}
load={() => loadDeclarationModule(declaration)}
/>
The frontend entrypoint owns module loading, React component validation, and
error-boundary diagnostics. It also exports createHostedDiagnostic and the
HostedDiagnostic, HostedDiagnosticPhase, and SourceMapLocation types for
hosts that report failures outside the provided render boundary. Host
applications still own catalog activation, routing, and product-specific
fallback UI.
Wrap the Host tree in HostedExtensionFailureProvider to replace the plain
failure message with the Host's own design-system UI. Its render callback
receives the HostedDiagnostic. Load failures are also logged with
console.error together with their original cause; React logs render failures.
HostedExtensionRender keeps the mounted component when props or callback
identities change, preserving local edits and focus. Loading restarts when the
declaration object or implementationRole changes; hosts should keep declarations
stable until the contribution changes. Pending load failures use the latest
onDiagnostic callback.
Workflow UI
The workflow entrypoint provides host-neutral workflow field and node
renderers, catalog activation, and query helpers:
import {
WorkflowFieldResolver,
activateWorkflowCatalog,
workflowFieldOptions
} from "@collective-intelligence/extensions-sdk-frontend-react/workflow"
Field bindings use inputBinding for node inputs and parameter for node
config. The exported read and patch helpers preserve sibling values and store
field display labels under the same canonical binding key in metadata. Stored
{mode: "fixed"} envelopes and compiled literals are unwrapped at every path
segment when a field renderer or a visibleWhen rule reads a binding;
expression values pass through unchanged. Visibility equals compares JSON
structurally, and in matches a scalar value or any member of a multi-value
field. CatalogNodeDetailsEditor renders sections and fields by ascending
order (default 0, ties keep declaration order). resource_selector and
credential_selector fields that resolve only to a widget fallback render as
the remote-options select, which shows the field's declared placeholder.
WorkflowFieldFrame and ExpressionSlot accept optional help text, shown below the field label. Custom fixed and expression controls should forward the render prop’s describedBy to their input’s aria-describedby, preserving any existing description IDs. The built-in expression editor combines this contextual help with validation descriptions. Read-only fields show the saved Fixed or Expression mode as text.
Opening expression suggestions preserves focus in the expression input or textarea, so typing continues in the authored value. The suggestion search remains explicitly focusable for filtering and inserting available values.
Hosts provide remote workflow-field options through
configureWorkflowFieldOptionsProvider. The SDK keeps that adapter outside
the public package so the same workflow UI can be used with different backend
clients and host applications.
Hosts can provide context.chart.presentation with an icon React component
(accepting an optional className) and a tone CSS class string. Use the node
type’s semantic icon and its category’s styling consistently in the catalog and
canvas. CatalogNodeChartBlock applies the supplied tone to the card and icon
badge, ahead of the renderer’s legacy accent. The default charts and built-in
chart overrides that delegate to this component share this behavior. Without
presentation, standalone consumers retain the kind-based icon and accent.
The host owns identity resolution; the public SDK does not import host catalogs
or private presentation registries.
Hosts give context.template.label and context.template.description to a
hosted renderer as WorkflowLocalizedText: { kind: "literal", text } or
{ kind: "translation", default }. A Host whose catalog transport uses another
shape converts it before it builds the context. CatalogNodeChartBlock shows
context.chart.title and context.chart.description when the Host sets them,
and the template text otherwise.
The chart exposes every declared input and output with its port name, direction, connection limit, and allowed peer constraints. Port targets are 20 pixels wide; the card grows to keep dense port lists and contextual add controls separated.
Studio API
The studio-api entrypoint is a generated client for the backend operations
that hosted Studio Widgets are meant to use. The backend marks each such
operation with the widget-public OpenAPI tag. The tag selects what the client
generates; it is not an authorization boundary. Each request runs with the
signed-in user's own permissions, and the backend authorizes it like any other
request. The entrypoint exports the SDK functions, their types, TanStack Query
options, mutation options and query keys, and the client instance. The Studio
Host binds that client to its authenticated transport; Widgets do not configure
credentials. Every Widget shares the one bound client, so the Host locks it:
client.setConfig and every client.interceptors change throw. Set headers
and other request options per call.
Studio view context
The Studio Host gives every placed view and Widget component a context prop
next to its configured props. The /studio entrypoint types it as
StudioViewComponentProps:
type StudioViewContext = Readonly<{
appId: string
appSlug: string
organizationId: string
pageId: string
publishedRevisionId: string
viewId: string
}>
type StudioViewComponentProps = Readonly<{
context: StudioViewContext | null
}>
Studio generates view, page, and revision ids when an app is installed or
published, so a declaration cannot name them; read them from context.
context is null when the view has no published placement: in the builder
preview, without an Organization, or before the app has a published revision.
Hide or disable the API calls of the Widget in that case.
studioViewScope(context) returns the headers and path options of the
view-data operations. studioPublishedViewScope(context) returns the
headers and query options of operations on a published view, such as
studioWorkflowRunsList:
import type {
StudioViewComponentProps,
StudioViewContext
} from "@collective-intelligence/extensions-sdk-frontend-react/studio"
import { studioViewScope } from "@collective-intelligence/extensions-sdk-frontend-react/studio"
import { studioViewDataSearchOptions } from "@collective-intelligence/extensions-sdk-frontend-react/studio-api"
import { useQuery } from "@tanstack/react-query"
const CustomerRows = ({ context }: { context: StudioViewContext }) => {
const rows = useQuery(
studioViewDataSearchOptions({
...studioViewScope(context),
body: { limit: 10 }
})
)
return <output>{rows.status}</output>
}
export const CustomerWidget = ({ context }: StudioViewComponentProps) =>
context === null ? null : <CustomerRows context={context} />
src/studio-api/ is generated and committed. After a backend change to a
marked operation, run bun run --filter @repo/backend api:openapi, then
bun run api:codegen:studio-api in this package. CI runs
api:codegen:studio-api:check and fails when the committed client is stale.
A new operation also needs its method in StudioApi; a test fails until it
has one.
Studio API on Effect
The /effect entrypoint exports the StudioApi service. It has one method per
operation of the studio-api client, with the same name, request options and
success type. StudioApi calls the shared studio-api client: it imports the
client through ./studio-api, so a Studio Widget calls the client that the
Studio Host binds. A Studio Extension release bundles /effect and keeps
/studio-api external; the release build needs a ci CLI version that
bundles an SDK ./effect subpath (see the CLI changelog). Only the Studio
Host provides /studio-api. A Dashboard release bundles its own copy of the
client, and no Host binds that copy.
/effect imports each operation of /studio-api by name. In a Studio
Extension, the Studio Host provides that module. If the /studio-api of the
Host does not export one of these operations, /effect does not load, also
for a Widget that does not call that operation. Use /effect only with a
Studio Host that ships this SDK version or a later one that keeps each
operation.
import type { StudioViewContext } from "@collective-intelligence/extensions-sdk-frontend-react/studio"
import {
layerStudioApi,
StudioApi
} from "@collective-intelligence/extensions-sdk-frontend-react/effect"
import { studioPublishedViewScope } from "@collective-intelligence/extensions-sdk-frontend-react/studio"
import * as Effect from "effect/Effect"
const runCount = (context: StudioViewContext) =>
Effect.gen(function* () {
const api = yield* StudioApi
const page = yield* api.studioWorkflowRunsList(
studioPublishedViewScope(context)
)
return page.data.length
}).pipe(
Effect.catchTag("StudioApiError", (error) => Effect.fail(error.status)),
Effect.provide(layerStudioApi())
)
- An error answer fails with
StudioApiError. It holds thestatusand thebodythat the client read (JSON, else text). The client does not checkbody, and a status outside the generated error map, such as a 502 of a proxy, holds any body. Sobodyisunknown: decode it with a Schema before use. - A request that does not reach the Backend, or an OK answer that is not
readable, fails with
StudioApiTransportError, which holds thecause. - Bad input, such as a body that JSON cannot write, is a
TypeErrorDefect. The request does not leave. - An interruption of the fiber aborts the request.
- The service sets
client,fetch,baseUrl,signal,responseStyleandthrowOnError. Set headers and the other request options per call.
layerStudioApi({ baseUrl }) sends each request to baseUrl. A Studio Widget
gives no base URL: the Host binds the client to its Backend and refuses other
URLs. A host that does not bind the client, such as a test or a preview, gives
the URL of its Backend. That URL is deployment configuration, so
layerStudioApiConfig({ baseUrl: Config.String("STUDIO_API_BASE_URL") })
reads it with Effect Config.
Writes in a Studio Widget
A Studio Widget follows the write convention of a detached app, and the
ci-solution/effect-writes and ci-solution/effect-actors lint rules apply to
it: a write runs in a fromEffect XState actor, views read with useQuery,
and after the write the actor refreshes the reads through QueryCache.
/effect exports these parts:
EffectRuntimeProvidergives its tree an Effect runtime with theQueryCacheover the nearestQueryClient, which is theQueryClientof the Studio Host. The Host mounts no Effect runtime. Each widget mountsEffectRuntimeProviderat its root: the bundle of each widget has its own copy of/effect, so it does not see the provider of another bundle.useEffectRuntime()gives that runtime. A view passes it to the machines it starts, in their input.fromEffect(run)makes XState actor logic that runs the effect ofrunoninput.runtime. A stop of the actor interrupts the fiber; after a stop, no result of that run gets to the actor, also in a React Strict Mode remount.QueryCachehasfetch,invalidate,cancel,setData,updateandremove.invalidate,cancel,updateandremoveapply to the key and to each key below it.fetchruns its query once, without a retry, and fails withStudioApiErrororStudioApiTransportError; another failure is a Defect. A cancel of the key interrupts the query; the waitingfetchthen returns the cached value, or is interrupted when the key has no value.cancelcancels the running fetches, so they write no value; it keeps the cached values and starts no fetch. An optimistic write callscancel, thenupdate, runs the write, and callsinvalidatewhen the write settles. The Host and all widgets share oneQueryClient, so start each key with the package name.makeTestQueryCache(options?)gives a machine test theQueryCacheover a realQueryClientof its own:layerforManagedRuntime.make,queryClientto seed and to assert on, andcalls, one{ member, queryKey }for each call that ran.options.overridesreplaces only the members that it names;options.seedholds the first entries. Test with it, never with a double made withQueryCache.of({ ... }), which breaks each timeQueryCachegets a member. It adds no dependency, and a widget bundle that does not import it does not contain it.
import type { EffectRunner } from "@collective-intelligence/extensions-sdk-frontend-react/effect"
import type { StudioViewContext } from "@collective-intelligence/extensions-sdk-frontend-react/studio"
import {
EffectRuntimeProvider,
fromEffect,
layerStudioApi,
QueryCache,
StudioApi
} from "@collective-intelligence/extensions-sdk-frontend-react/effect"
import { studioViewScope } from "@collective-intelligence/extensions-sdk-frontend-react/studio"
import * as Effect from "effect/Effect"
import { setup } from "xstate"
const customersKey = ["com.acme.crm", "customers"] as const
type RenameInput = Readonly<{
context: StudioViewContext
instanceId: string
name: string
runtime: EffectRunner<QueryCache>
}>
export const renameMachine = setup({
actors: {
rename: fromEffect((input: RenameInput) =>
Effect.gen(function* () {
const api = yield* StudioApi
const scope = studioViewScope(input.context)
yield* api.studioViewDataUpdateItem({
...scope,
body: { name: input.name },
path: { ...scope.path, instanceId: input.instanceId }
})
const cache = yield* QueryCache
yield* cache.invalidate(customersKey)
}).pipe(Effect.provide(layerStudioApi()))
)
}
}).createMachine({/* states that invoke "rename" with the input above */})
// The widget root: the runtime is over the QueryClient of the Studio Host.
export const CustomersWidget = (props: CustomersViewProps) => (
<EffectRuntimeProvider>
<CustomersView {...props} />
</EffectRuntimeProvider>
)
xstate (^5.33.3) is a required peer. The Studio and Dashboard Hosts provide
their one copy of xstate, and the ci CLI keeps xstate external in both
Hosts' artifacts, so a widget bundle carries no copy of XState. The first
Hosts that provide xstate ship in the first ci-app release after
@collective-intelligence/[email protected], together with the CLI that keeps it
external: a release built with that CLI or a later one runs on Hosts of that
ci-app release or a later one. A widget that uses @xstate/react bundles it;
it imports the Host copies of react and xstate.
Import XState only from xstate: the Host provides that one name, and a
release bundles a subpath such as xstate/actors, which is a second copy of
XState. The Host does not check the xstate version of a widget: build
against the xstate 5 that the Hosts ship.
Building in the workspace
This package needs the built @collective-intelligence/ui-react declarations
to compile its own declarations. From the repository root, run
bunx turbo run build --filter=@collective-intelligence/extensions-sdk-frontend-react
to build this package and its dependencies in order. The workspace development
dependency on ui-react supplies that build ordering; it remains an optional
peer for consumers of the published package.
Hosted module references may supply a load callback alongside module and
exportName. loadDeclarationModule prefers that callback for the requested
implementation role, falling back to the render role when absent. Without a
callback it imports the module locator. Keep actual renderer functions reachable
through the callback so bundlers retain them in catalog-only entrypoints. The
callback is a runtime sidecar and is excluded from canonical package metadata.
Hosted workflow field, configuration, and template declarations require a load
callback. Their render paths reject locator-only implementations so hosted
bundles contain no computed imports outside the declared module graph. The
general loadDeclarationModule API retains its locator fallback for host-owned
loading outside those workflow render paths.
The package build emits production JSX while keeping React and React DOM external.
The built /frontend renderer is tested with both production and development
consumer React peers, including its loading state and loaded component props.
Enum value spelling
Repository-owned enum values use lower snake_case. This includes component kinds, dataset statuses, schema consumer profiles, and hosted contribution identity. Notification enum values retain their existing spelling. External protocol values, including HTTP methods and JSON Schema keywords and formats, retain the protocol spelling.
Query related records
The generated /studio-api export includes studioViewDataSearchMembers. It queries
POST /studio/apps/by-slug/{slug}/views/{viewId}/data/{instanceId}/relation-members/search
with the published view's authorization scope. Its generated body accepts the relation
field, filters, sort, cursor, limit and include paths. Filters and sort address the
related schema. Forward and inverse fields use the same operation, including scalar
relations (zero or one result). Preserve the generated response's pagination envelope.
A successful page and its relation IDs are complete. Per-field includes can carry the
existing truncation marker; paging within nested includes is not supported. A typed
KnowledgeReadLimitExceeded response requires narrowing the query, page, or includes;
do not silently trim the response or retry the unchanged oversized request.
Host-provided workflow User selection
WorkflowFieldComponentContext.userSelection is an optional Host renderer for
Organization member selection. It receives multiple, value, onChange,
controlId, labelledBy, readOnly, and required; it returns a React node.
Values and changes contain only user IDs (null for an empty single selection,
[] for an empty list). The Host binds resource-authorized search and selected-ID
resolution; public contributions never import the Host's private picker or data
client. A contribution that requires this renderer must show an unavailable
state if the Host omits it, rather than accepting arbitrary text input.
Catalog adapters preserve the explicit field.user: { multiple } descriptor in
WorkflowFieldCatalogContract. A UUID schema does not activate User selection.