Skip to content

Add a surface

By the end of this page a link has a view, where a person can edit its title, address and note, and see whether they have read it.

A surface is a named place in an app’s UI, such as the main article area, a card or the status bar. Plugins fill surfaces by contributing a component and a filter that says which data the component can render. When Composer opens a link in the article area, it asks for every component that matches the article role and a link, and renders the best one. Surfaces explains the matching.

  1. Write the component. The article surface passes the object as subject. useObject returns a snapshot of it and re-renders the component when it changes, whether the change came from this tab, another device or the assistant. Writes go through Obj.update, like any ECHO write.

    src/containers/StashedLinkArticle.tsx
    import type * as
    import AppSurface
    AppSurface
    from '@dxos/app-toolkit/AppSurface';
    import {
    import Obj
    Obj
    } from '@dxos/echo';
    import {
    const useObject: {
    <T extends Obj.Unknown>(ref: Ref<T>): [Obj.Snapshot<T> | undefined, ObjectUpdateCallback<T>];
    <T extends Obj.Unknown>(ref: Ref<T> | undefined): [Obj.Snapshot<T> | undefined, ObjectUpdateCallback<T>];
    <T extends Obj.Unknown>(ref: Ref<T> | undefined, options: LoadOptions): [Obj.Snapshot<T> | undefined, ObjectUpdateCallback<T>];
    <T extends Obj.Unknown>(obj: T): [Obj.Snapshot<T>, ObjectUpdateCallback<T>];
    <T extends Obj.Unknown>(obj: T | undefined): [Obj.Snapshot<T> | undefined, ObjectUpdateCallback<T>];
    <T extends Obj.Unknown>(objOrRef: T | Ref<T> | undefined): [Obj.Snapshot<T> | undefined, ObjectUpdateCallback<T>];
    <T extends Obj.Unknown, K extends keyof T>(obj: T, property: K): [T[K], ObjectPropUpdateCallback<T[K]>];
    <T extends Obj.Unknown, K extends keyof T>(obj: T | undefined, property: K): [T[K] | undefined, ObjectPropUpdateCallback<T[K]>];
    <T, K extends keyof T>(ref: Ref<T>, property: K): [T[K] | undefined, ObjectPropUpdateCallback<T[K]>];
    <T, K extends keyof T>(ref: Ref<T> | undefined, property: K): [T[K] | undefined, ObjectPropUpdateCallback<T[K]>];
    }
    useObject
    } from '@dxos/echo-react';
    import * as
    import Input
    Input
    from '@dxos/react-ui/Input';
    import * as
    import Layout
    Layout
    from '@dxos/react-ui/Layout';
    import * as
    import Panel
    Panel
    from '@dxos/react-ui/Panel';
    import { type
    class StashedLink
    StashedLink
    } from '../types/StashedLink.ts';
    export type
    type StashedLinkArticleProps = {
    attendableId: string;
    nodeId?: string;
    subject: StashedLink;
    properties?: Record<string, any>;
    variant?: string;
    path?: string[];
    popoverAnchorId?: string;
    extrinsic?: boolean;
    } & {
    companionTo?: unknown;
    } & {
    role?: string;
    }
    StashedLinkArticleProps
    =
    import AppSurface
    AppSurface
    .
    type ObjectArticleProps<Subject extends Obj.Unknown | undefined = Obj.Unknown, Props extends {} = {}, CompanionTo = unknown> = {
    attendableId: string;
    nodeId?: string;
    subject: Subject;
    properties?: Record<string, any>;
    variant?: string;
    path?: string[];
    popoverAnchorId?: string;
    extrinsic?: boolean;
    } & (unknown extends CompanionTo ? {
    companionTo?: CompanionTo | undefined;
    } : {
    companionTo: CompanionTo;
    }) & Props & {
    role?: string;
    }

    Component props for article-role ECHO object.

    ObjectArticleProps
    <
    class StashedLink
    StashedLink
    >;
    export const
    const StashedLinkArticle: ({ role, subject, }: StashedLinkArticleProps) => React.JSX.Element
    StashedLinkArticle
    = ({
    role: string | undefined
    role
    ,
    subject: StashedLink
    subject
    ,
    }:
    type StashedLinkArticleProps = {
    attendableId: string;
    nodeId?: string;
    subject: StashedLink;
    properties?: Record<string, any>;
    variant?: string;
    path?: string[];
    popoverAnchorId?: string;
    extrinsic?: boolean;
    } & {
    companionTo?: unknown;
    } & {
    role?: string;
    }
    StashedLinkArticleProps
    ) => {
    // A snapshot that re-renders the component when the object changes, here or
    // on another peer.
    const [
    const link: Obj.Snapshot<StashedLink>
    link
    ] =
    useObject<StashedLink>(obj: StashedLink): [Obj.Snapshot<StashedLink>, ObjectUpdateCallback<StashedLink>] (+9 overloads)

    Hook to subscribe to an entire Echo object. Returns a snapshot of the current object value and automatically re-renders when the object changes.

    @param ― obj - The Echo object to subscribe to (objects only, not relations)

    @returns ― The current object snapshot and update callback

    useObject
    (
    subject: StashedLink
    subject
    );
    return (
    <
    import Panel
    Panel
    .
    const Root: React.ForwardRefExoticComponent<Omit<Panel.RootProps & {
    asChild?: boolean;
    }, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLDivElement>>
    export Root

    The plank host: fills its parent, sets data-size and a level for its subtree, and is the pane's query container, so its header, body and footer collapse at the same pane width.

    Root
    role?: React.AriaRole | undefined
    role
    ={
    role: string | undefined
    role
    }>
    <
    import Panel
    Panel
    .
    const Body: React.ForwardRefExoticComponent<Omit<{
    asChild?: boolean;
    }, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLDivElement>>
    export Body

    The growing middle row, a plain slot: it neither scrolls nor adds a gutter, so content composes its own frame (asChild onto a ScrollArea.Root around a Container, or a canvas that fills the row). The first Container under it takes the panel's gutter unless it names one. Like the header and footer it is no query container, so its content collapses against the panel.

    Body
    asChild?: boolean | undefined
    asChild
    >
    <
    import Layout
    Layout
    .
    const Flex: React.ForwardRefExoticComponent<Omit<Layout.FlexProps & {
    asChild?: boolean;
    }, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLDivElement>>

    One-axis layout box: the primitive that replaces hand-written flex … wrapper divs.

    gap is restricted to the named steps of the theme spacing ramp (see

    Gap

    ), which is the point of the prop — an off-ramp gap-2/gap-3 literal in a container is the drift it prevents. Anything the props don't cover (padding, sizing, overflow, colour) goes through classNames; the component deliberately grows no padding or colour props, since components own their own spacing.

    There is no implicit align. Row-centering is the common case, but defaulting it would silently restyle any consumer relying on the CSS stretch initial value.

    Use asChild to project the layout onto a semantic element (<header>, <ul>, <a>).

    @example

    <Flex column gap='sm'>…</Flex>
    <Flex gap='sm' justify='end'>…</Flex>
    <Flex center classNames='h-full text-fg-subtle' role='status'>{t('empty.message')}</Flex>

    Flex
    column?: boolean | undefined

    Stack on the block axis instead of the inline axis.

    column
    gap?: Layout.Gap | undefined
    gap
    ='sm'
    classNames?: ClassNameValue
    classNames
    ='p-2'>
    <
    import Input
    Input
    .
    const Root: React.ForwardRefExoticComponent<Omit<Input.RootProps, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLInputElement>>
    export Root

    Text input at control size; inside a Field.Root it takes the field's id, label and description wiring. With start or end it renders a ControlFrame holding the adornments and a bare input; data-testid and classes then go to the frame, the ref to the input.

    Root
    placeholder?: string | undefined
    placeholder
    ='Title'
    value?: string | number | readonly string[] | undefined
    value
    ={
    const link: Obj.Snapshot<StashedLink>
    link
    .
    title?: string | undefined
    title
    ?? ''}
    onChange?: React.ChangeEventHandler<HTMLInputElement, HTMLInputElement> | undefined
    onChange
    ={(
    event: React.ChangeEvent<HTMLInputElement, HTMLInputElement>
    event
    ) =>
    import Obj
    Obj
    .
    const update: <StashedLink>(obj: StashedLink, callback: ChangeCallback<StashedLink>) => StashedLink

    Perform mutations on an echo object within a controlled context.

    All mutations within the callback are batched and trigger a single notification when the callback completes. Direct mutations outside of Obj.update will throw an error for echo objects.

    This function also works with nested objects within echo objects (e.g., Template structs) that are reactive at runtime.

    @param ― obj - The echo object to mutate. Use Relation.update for relations.

    @param ― callback - The callback that performs mutations on the object.

    @example

    const person = Obj.make(Person, { name: 'John', age: 25 });
    // Mutate within Obj.update
    Obj.update(person, (obj) => {
    obj.name = 'Jane';
    obj.age = 30;
    });
    // ONE notification fires here
    // Direct mutation throws
    person.name = 'Bob'; // Error: Cannot modify outside Obj.update()

    Note: Only accepts objects. Use Relation.update for relations.

    @performance ― Synchronous; costs the mutations made in the callback plus one batched notification and an O(owning fields) parent pass.

    update
    (
    subject: StashedLink
    subject
    , (
    subject: {
    title?: string | undefined;
    url?: string | undefined;
    note?: string | undefined;
    read?: boolean | undefined;
    id: EntityId;
    "~@dxos/echo/Kind": EntityKind.Object;
    }
    subject
    ) => {
    subject: {
    title?: string | undefined;
    url?: string | undefined;
    note?: string | undefined;
    read?: boolean | undefined;
    id: EntityId;
    "~@dxos/echo/Kind": EntityKind.Object;
    }
    subject
    .
    title?: string | undefined
    title
    =
    event: React.ChangeEvent<HTMLInputElement, HTMLInputElement>
    event
    .
    ChangeEvent<HTMLInputElement, HTMLInputElement>.target: EventTarget & HTMLInputElement
    target
    .
    HTMLInputElement.value: string

    The value property of the HTMLInputElement interface represents the current value of the element as a string.

    MDN Reference

    value
    ;
    })
    }
    />
    <
    import Input
    Input
    .
    const Root: React.ForwardRefExoticComponent<Omit<Input.RootProps, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLInputElement>>
    export Root

    Text input at control size; inside a Field.Root it takes the field's id, label and description wiring. With start or end it renders a ControlFrame holding the adornments and a bare input; data-testid and classes then go to the frame, the ref to the input.

    Root
    placeholder?: string | undefined
    placeholder
    ='https://'
    value?: string | number | readonly string[] | undefined
    value
    ={
    const link: Obj.Snapshot<StashedLink>
    link
    .
    url?: string | undefined
    url
    ?? ''}
    onChange?: React.ChangeEventHandler<HTMLInputElement, HTMLInputElement> | undefined
    onChange
    ={(
    event: React.ChangeEvent<HTMLInputElement, HTMLInputElement>
    event
    ) =>
    import Obj
    Obj
    .
    const update: <StashedLink>(obj: StashedLink, callback: ChangeCallback<StashedLink>) => StashedLink

    Perform mutations on an echo object within a controlled context.

    All mutations within the callback are batched and trigger a single notification when the callback completes. Direct mutations outside of Obj.update will throw an error for echo objects.

    This function also works with nested objects within echo objects (e.g., Template structs) that are reactive at runtime.

    @param ― obj - The echo object to mutate. Use Relation.update for relations.

    @param ― callback - The callback that performs mutations on the object.

    @example

    const person = Obj.make(Person, { name: 'John', age: 25 });
    // Mutate within Obj.update
    Obj.update(person, (obj) => {
    obj.name = 'Jane';
    obj.age = 30;
    });
    // ONE notification fires here
    // Direct mutation throws
    person.name = 'Bob'; // Error: Cannot modify outside Obj.update()

    Note: Only accepts objects. Use Relation.update for relations.

    @performance ― Synchronous; costs the mutations made in the callback plus one batched notification and an O(owning fields) parent pass.

    update
    (
    subject: StashedLink
    subject
    , (
    subject: {
    title?: string | undefined;
    url?: string | undefined;
    note?: string | undefined;
    read?: boolean | undefined;
    id: EntityId;
    "~@dxos/echo/Kind": EntityKind.Object;
    }
    subject
    ) => {
    subject: {
    title?: string | undefined;
    url?: string | undefined;
    note?: string | undefined;
    read?: boolean | undefined;
    id: EntityId;
    "~@dxos/echo/Kind": EntityKind.Object;
    }
    subject
    .
    url?: string | undefined
    url
    =
    event: React.ChangeEvent<HTMLInputElement, HTMLInputElement>
    event
    .
    ChangeEvent<HTMLInputElement, HTMLInputElement>.target: EventTarget & HTMLInputElement
    target
    .
    HTMLInputElement.value: string

    The value property of the HTMLInputElement interface represents the current value of the element as a string.

    MDN Reference

    value
    ;
    })
    }
    />
    <
    import Input
    Input
    .
    const Textarea: React.ForwardRefExoticComponent<Omit<Input.TextareaProps, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLTextAreaElement>>
    export Textarea

    Multi-line text at control width; inside a Field.Root it takes the field's id, label and description wiring.

    Textarea
    placeholder?: string | undefined
    placeholder
    ='Why is this worth keeping?'
    value?: string | number | readonly string[] | undefined
    value
    ={
    const link: Obj.Snapshot<StashedLink>
    link
    .
    note?: string | undefined
    note
    ?? ''}
    onChange?: React.ChangeEventHandler<HTMLTextAreaElement, HTMLTextAreaElement> | undefined
    onChange
    ={(
    event: React.ChangeEvent<HTMLTextAreaElement, HTMLTextAreaElement>
    event
    ) =>
    import Obj
    Obj
    .
    const update: <StashedLink>(obj: StashedLink, callback: ChangeCallback<StashedLink>) => StashedLink

    Perform mutations on an echo object within a controlled context.

    All mutations within the callback are batched and trigger a single notification when the callback completes. Direct mutations outside of Obj.update will throw an error for echo objects.

    This function also works with nested objects within echo objects (e.g., Template structs) that are reactive at runtime.

    @param ― obj - The echo object to mutate. Use Relation.update for relations.

    @param ― callback - The callback that performs mutations on the object.

    @example

    const person = Obj.make(Person, { name: 'John', age: 25 });
    // Mutate within Obj.update
    Obj.update(person, (obj) => {
    obj.name = 'Jane';
    obj.age = 30;
    });
    // ONE notification fires here
    // Direct mutation throws
    person.name = 'Bob'; // Error: Cannot modify outside Obj.update()

    Note: Only accepts objects. Use Relation.update for relations.

    @performance ― Synchronous; costs the mutations made in the callback plus one batched notification and an O(owning fields) parent pass.

    update
    (
    subject: StashedLink
    subject
    , (
    subject: {
    title?: string | undefined;
    url?: string | undefined;
    note?: string | undefined;
    read?: boolean | undefined;
    id: EntityId;
    "~@dxos/echo/Kind": EntityKind.Object;
    }
    subject
    ) => {
    subject: {
    title?: string | undefined;
    url?: string | undefined;
    note?: string | undefined;
    read?: boolean | undefined;
    id: EntityId;
    "~@dxos/echo/Kind": EntityKind.Object;
    }
    subject
    .
    note?: string | undefined
    note
    =
    event: React.ChangeEvent<HTMLTextAreaElement, HTMLTextAreaElement>
    event
    .
    ChangeEvent<HTMLTextAreaElement, HTMLTextAreaElement>.target: EventTarget & HTMLTextAreaElement
    target
    .
    HTMLTextAreaElement.value: string

    The value property of the HTMLTextAreaElement interface represents the value of the element as a string, which is an empty string if the widget contains no content. It returns or sets the raw value contained in the control.

    MDN Reference

    value
    ;
    })
    }
    />
    <
    JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>
    p
    HTMLAttributes<HTMLParagraphElement>.className?: string | undefined
    className
    ='text-description'>
    {
    const link: Obj.Snapshot<StashedLink>
    link
    .
    read?: boolean | undefined
    read
    ? 'Read' : 'Not read yet'}
    </
    JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>
    p
    >
    </
    import Layout
    Layout
    .
    const Flex: React.ForwardRefExoticComponent<Omit<Layout.FlexProps & {
    asChild?: boolean;
    }, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLDivElement>>

    One-axis layout box: the primitive that replaces hand-written flex … wrapper divs.

    gap is restricted to the named steps of the theme spacing ramp (see

    Gap

    ), which is the point of the prop — an off-ramp gap-2/gap-3 literal in a container is the drift it prevents. Anything the props don't cover (padding, sizing, overflow, colour) goes through classNames; the component deliberately grows no padding or colour props, since components own their own spacing.

    There is no implicit align. Row-centering is the common case, but defaulting it would silently restyle any consumer relying on the CSS stretch initial value.

    Use asChild to project the layout onto a semantic element (<header>, <ul>, <a>).

    @example

    <Flex column gap='sm'>…</Flex>
    <Flex gap='sm' justify='end'>…</Flex>
    <Flex center classNames='h-full text-fg-subtle' role='status'>{t('empty.message')}</Flex>

    Flex
    >
    </
    import Panel
    Panel
    .
    const Body: React.ForwardRefExoticComponent<Omit<{
    asChild?: boolean;
    }, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLDivElement>>
    export Body

    The growing middle row, a plain slot: it neither scrolls nor adds a gutter, so content composes its own frame (asChild onto a ScrollArea.Root around a Container, or a canvas that fills the row). The first Container under it takes the panel's gutter unless it names one. Like the header and footer it is no query container, so its content collapses against the panel.

    Body
    >
    </
    import Panel
    Panel
    .
    const Root: React.ForwardRefExoticComponent<Omit<Panel.RootProps & {
    asChild?: boolean;
    }, "className"> & {
    classNames?: import("@dxos/ui-types").ClassNameValue;
    } & Pick<React.HTMLAttributes<Element>, "role" | "children" | "style"> & React.RefAttributes<HTMLDivElement>>
    export Root

    The plank host: fills its parent, sets data-size and a level for its subtree, and is the pane's query container, so its header, body and footer collapse at the same pane width.

    Root
    >
    );
    };

    Panel.Root with the surface’s role gives the view the standard article layout.

  2. Contribute the surface. AppSurface.object(AppSurface.Article, StashedLink) matches the article role when the subject is a link. props maps the surface’s data onto the component’s props, and is type-checked against the filter.

    src/capabilities/react-surface.ts
    import * as
    import Effect
    Effect
    from 'effect/Effect';
    import * as
    import Capabilities
    Capabilities
    from '@dxos/app-framework/Capabilities';
    import * as
    import Capability
    Capability
    from '@dxos/app-framework/Capability';
    import * as
    import Surface
    Surface
    from '@dxos/app-framework/Surface';
    import * as
    import AppSurface
    AppSurface
    from '@dxos/app-toolkit/AppSurface';
    import {
    const StashedLinkArticle: ({ role, subject, }: StashedLinkArticleProps) => JSX.Element
    StashedLinkArticle
    } from '../containers/StashedLinkArticle.tsx';
    import {
    class StashedLink
    StashedLink
    } from '../types/StashedLink.ts';
    export default
    import Capability
    Capability
    .
    const makeModule: <void, Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.reactSurface", "multi">>, never, never>(fn: (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.reactSurface", "multi">>, never, Scope>) => (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.reactSurface", "multi">>, never, Scope>

    Helper to define a capability module with explicit typing. Wraps the default export function to provide better type inference and make the pattern explicit.

    This helper provides explicit typing for the module activation function, making it clear that the function should:

    • Access declared requires via yield* (or the ambient Capability.Service/Plugin.Service)
    • Return an array of typed

    Contribution

    s (see

    contribute

    /

    contributeAll

    )

    @example

    // Module without options - single capability
    export default Capability.makeModule(
    Effect.fnUntraced(function* () {
    const client = yield* ClientCapabilities.Client;
    return Capability.contribute(Capabilities.SettingsStore, store);
    })
    );
    // Module with multiple capabilities
    export default Capability.makeModule(
    Effect.fnUntraced(function* () {
    return [
    Capability.contribute(Capabilities.SettingsStore, store),
    Capability.contribute(Capabilities.Translations, translations),
    ];
    })
    );
    // Module with required options (context accessed via layer)
    export default Capability.makeModule(
    Effect.fnUntraced(function* (props: { observability: boolean }) {
    const invoker = yield* Capabilities.OperationInvoker;
    return Capability.contribute(Capabilities.OperationHandler, ...);
    })
    );
    // Module with scoped resources (closed automatically on deactivation)
    export default Capability.makeModule(
    Effect.fnUntraced(function* () {
    const scope = yield* Scope.Scope;
    yield* Scope.addFinalizer(scope, Effect.sync(() => cleanup()));
    return Capability.contribute(Capabilities.MyCapability, implementation);
    })
    );

    makeModule
    (() =>
    import Effect
    Effect
    .
    const succeed: <Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.reactSurface", "multi">>>(value: Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.reactSurface", "multi">>) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.reactSurface", "multi">>, never, never>

    Creates an Effect that always succeeds with a given value.

    When to use

    Use when an effect should complete successfully with a specific value without any errors or external dependencies.

    Example (Creating a successful effect)

    import { Effect } from "effect"
    // Creating an effect that represents a successful scenario
    //
    // ┌─── Effect<number, never, never>
    // ▼
    const success = Effect.succeed(42)
    Effect.runSync(success) // => 42

    @see ― fail to create an effect that represents a failure.

    @stability ― stable

    @category ― constructors

    @since ― 2.0.0

    succeed
    (
    import Capability
    Capability
    .
    const contribute: <Capability.MultiTag<any, "org.dxos.app-framework.capability.reactSurface">>(capability: Capability.MultiTag<any, "org.dxos.app-framework.capability.reactSurface">, implementation: any) => Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.reactSurface", "multi">> (+1 overload)
    contribute
    (
    import Capabilities
    Capabilities
    .
    const ReactSurface: Capability.MultiTag<any, "org.dxos.app-framework.capability.reactSurface">

    Surface definitions that can be either React components or Web Components.

    @category ― Capability

    ReactSurface
    ,
    import Surface
    Surface
    .
    const create: <Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
    subject: StashedLink & OfKind<EntityKind.Object>;
    }, {
    role: string;
    subject: StashedLink & OfKind<EntityKind.Object>;
    attendableId: string;
    }, "article">(definition: Readonly<{
    id: "article";
    filter: Filter<Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
    subject: StashedLink & OfKind<EntityKind.Object>;
    }>;
    position?: Position;
    component: ComponentType<...>;
    props?: ((props: ComponentProps<...>) => {
    ...;
    }) | undefined;
    }>) => ReactDefinition<Omit<...> & {
    subject: StashedLink & OfKind<EntityKind.Object>;
    }>

    Creates a React surface definition from a typed filter.

    create
    ({
    id: "article"
    id
    : 'article',
    filter: Filter<Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
    subject: StashedLink & OfKind<EntityKind.Object>;
    }>
    filter
    :
    import AppSurface
    AppSurface
    .
    const object: <Role<AppSurface.ArticleData<any, {}, unknown>>, typeof StashedLink>(token: Role<AppSurface.ArticleData<any, {}, unknown>>, schema: typeof StashedLink, predicate?: ((data: NonNullable<AppSurface.ArticleData<any, {}, unknown>>) => boolean) | undefined) => Surface.Filter<Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
    subject: StashedLink & OfKind<EntityKind.Object>;
    }> (+1 overload)
    object
    (
    import AppSurface
    AppSurface
    .
    const Article: Role<AppSurface.ArticleData<any, {}, unknown>>

    Role token for the article role. Subject defaults to any until narrowed via

    object

    ,

    literal

    , or

    subject

    . Extra passthrough props are permitted.

    Article
    ,
    class StashedLink
    StashedLink
    ),
    component: ComponentType<{
    role: string;
    subject: StashedLink & OfKind<EntityKind.Object>;
    attendableId: string;
    }>

    Accepts any component type (not just a function) so a container re-exported through a lazy() barrel as ComponentType<any> can be registered without a cast.

    component
    :
    const StashedLinkArticle: ({ role, subject, }: StashedLinkArticleProps) => JSX.Element
    StashedLinkArticle
    ,
    props?: ((props: ComponentProps<Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
    subject: StashedLink & OfKind<EntityKind.Object>;
    }>) => {
    role: string;
    subject: StashedLink & OfKind<EntityKind.Object>;
    attendableId: string;
    }) | undefined

    Maps the surface props onto the component's own props, so a plain container can be registered directly instead of being wrapped in an adapter that unpacks data.

    Prefer this over an inline component: ({ data }) => <Container … />: the mapper's input type derives from the same filter that defines the surface's data shape, so the unpacking is checked against the filter rather than restated by hand.

    @example Surface.create({ id: 'defaultPluginSettings', filter: AppSurface.settings(AppSurface.Article), component: DefaultSettings, props: ({ data: { subject } }) => ({ subject }), });

    props
    : ({
    role: string

    The resolved role NSID string (e.g. org.dxos.role.article). Present on matched components only — multi-role containers can branch on this value by comparing against a token's .role (e.g. role === AppSurface.Section.role).

    role
    ,
    data: Omit<NonNullable<AppSurface.ArticleData<any, {}, unknown>>, "subject"> & {
    subject: StashedLink & OfKind<EntityKind.Object>;
    }
    data
    : {
    subject: StashedLink & OfKind<EntityKind.Object>
    subject
    ,
    attendableId: string
    attendableId
    } }) => ({
    role: string
    role
    ,
    subject: StashedLink & OfKind<EntityKind.Object>
    subject
    ,
    attendableId: string
    attendableId
    ,
    }),
    }),
    ),
    ),
    );
  3. Add the module to the manifest. The roles list lets the plugin manager load the module only when it first needs an article surface.

    dx.config.ts
    import * as
    import AppCapability
    AppCapability
    from '@dxos/app-toolkit/AppCapability';
    import * as
    import AppSurface
    AppSurface
    from '@dxos/app-toolkit/AppSurface';
    42 collapsed lines
    import * as
    import SpaceCapability
    SpaceCapability
    from '@dxos/plugin-space/SpaceCapability';
    import {
    const StashNamespace: {
    key: "com.example.plugin.stash";
    make: <T>() => <const N extends string>(name: N) => MultiTag<T, `com.example.plugin.stash.capability.${N}`>;
    makeSingleton: <T>() => <const N extends string>(name: N) => Tag<T, `com.example.plugin.stash.capability.${N}`>;
    }
    StashNamespace
    } from './src/namespace.ts';
    const
    const config: Plugin.PluginConfig<void>
    config
    :
    import Plugin
    Plugin
    .
    type PluginConfig<T = void> = {
    readonly plugin: {
    readonly key: string;
    readonly name: string;
    readonly description?: string | undefined;
    readonly author?: string | undefined;
    readonly homePage?: string | undefined;
    readonly source?: string | undefined;
    readonly screenshots?: readonly {
    readonly light?: string | undefined;
    readonly dark?: string | undefined;
    }[] | undefined;
    readonly tags?: readonly string[] | undefined;
    readonly icon?: {
    readonly key: string;
    readonly hue?: string | undefined;
    } | undefined;
    readonly spec?: string | undefined;
    readonly dependsOn?: readonly string[] | undefined;
    };
    readonly publish?: {
    ...;
    } | undefined;
    } & {
    ...;
    }

    A plugin's dx.config.ts default export: its config data, runtime

    Meta

    and factory.

    PluginConfig
    =
    import Plugin
    Plugin
    .
    const config: <void>({ modules, ...data }: Plugin.ConfigOptions) => Plugin.PluginConfig<void>

    Defines a plugin entirely from its dx.config.ts.

    @example

    // dx.config.ts
    export default Plugin.config({
    plugin: { key: 'org.dxos.plugin.markdown', name: 'Markdown' },
    modules: [AppCapability.schema(() => import('./src/capabilities/schema.ts'))],
    });

    config
    ({
    plugin: {
    readonly key: string;
    readonly name: string;
    readonly description?: string | undefined;
    readonly author?: string | undefined;
    readonly homePage?: string | undefined;
    readonly source?: string | undefined;
    readonly screenshots?: readonly {
    readonly light?: string | undefined;
    readonly dark?: string | undefined;
    }[] | undefined;
    readonly tags?: readonly string[] | undefined;
    readonly icon?: {
    readonly key: string;
    readonly hue?: string | undefined;
    } | undefined;
    readonly spec?: string | undefined;
    readonly dependsOn?: readonly string[] | undefined;
    }
    plugin
    : {
    key: string

    Reverse-domain NSID — the plugin's globally-unique key (e.g. org.dxos.plugin.excalidraw).

    key
    :
    const StashNamespace: {
    key: "com.example.plugin.stash";
    make: <T>() => <const N extends string>(name: N) => MultiTag<T, `com.example.plugin.stash.capability.${N}`>;
    makeSingleton: <T>() => <const N extends string>(name: N) => Tag<T, `com.example.plugin.stash.capability.${N}`>;
    }
    StashNamespace
    .
    key: "com.example.plugin.stash"
    key
    ,
    name: string
    name
    : 'Stash',
    author?: string | undefined

    Author or organization name. Only used for bundled plugins. For plugins published to the registry this field is ignored — the verified publisher (handle ?? did) is used instead.

    author
    : 'Example',
    description?: string | undefined
    description
    :
    'A reading list: save links with a note, and mark them as read.',
    icon?: {
    readonly key: string;
    readonly hue?: string | undefined;
    } | undefined
    icon
    : {
    key: string
    key
    : 'ph--tray-arrow-down--regular',
    hue?: string | undefined
    hue
    : 'indigo' },
    tags?: readonly string[] | undefined
    tags
    : ['labs'],
    },
    modules: readonly (Module<any> | undefined)[]

    Module specs only; each body is a dynamic import, so reading the config loads no plugin code.

    modules
    : [
    import AppCapability
    AppCapability
    .
    const translations: (resources: Resource | Resource[] | (() => Promise<{
    translations: Resource | Resource[];
    }>), options?: {
    name?: string;
    environments?: readonly Environment[];
    }) => Module<void>

    Module contributing translations.

    translations
    (
    () => import('./src/translations.ts', {
    ImportCallOptions.with?: ImportAttributes | undefined
    with
    : {
    type: string
    type
    : 'module-url' } }),
    ),
    import AppCapability
    AppCapability
    .
    const schema: (types: ReadonlyArray<AnyEntity> | (() => Promise<{
    default: ReadonlyArray<AnyEntity>;
    }>), options?: {
    name?: string;
    environments?: readonly Environment[];
    }) => Module<void>

    Module contributing schemas. Prefer the loader form — schema objects ride whatever barrels declare them, so an inline list drags those barrels into the plugin definition's static closure (the boot evaluation floor); a loader keeps them in the module body chunk.

    schema
    (
    () =>
    import('./src/capabilities/schema.ts', {
    ImportCallOptions.with?: ImportAttributes | undefined
    with
    : {
    type: string
    type
    : 'module-url' },
    }),
    ),
    import AppCapability
    AppCapability
    .
    const operationHandler: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<OperationHandlerSet, "org.dxos.app-framework.capability.operationHandler">]>, options?: MakerOptions<readonly [], readonly [], void, any> | undefined) => Module<any>

    Module maker contributing operation handlers. Handler sets register eagerly by default: a keyed set is a definition→loader map with a thin closure (definitions carry no implementations), so the operation registry is complete at boot while each handler's BODY still loads per invocation. The deferral axis is the operation, not the module. A feature plugin whose operations cannot be invoked before the app is interactive declares activatesOn: ActivationEvents.Idle to register in that wave instead of on the startup pass; the boot path (deck, layout, space, client) invokes operations during startup and stays eager.

    Stated explicitly rather than inherited: omitting activatesOn now normalizes to Idle, which would leave the registry incomplete for exactly those boot-path invocations.

    operationHandler
    (
    () =>
    import('./src/capabilities/operation-handler.ts', {
    ImportCallOptions.with?: ImportAttributes | undefined
    with
    : {
    type: string
    type
    : 'module-url' },
    }),
    ),
    import SpaceCapability
    SpaceCapability
    .
    const createObject: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<Readonly<{
    id: string;
    createObject: import("@dxos/plugin-space/SpaceSchema").CreateObject;
    inputSchema?: import("effect/Schema").Codec<any, any>;
    customPanel?: import("react").ComponentType<CreateObjectCustomPanelProps>;
    }>, "org.dxos.plugin.space.capability.createObject">]>, options?: MakerOptions<readonly [], readonly [], void, any> | undefined) => Module<any>

    Module maker contributing a typed create-object entry. Gated by default on the create flow opening (all consumers read the entries reactively); declare activatesOn to override.

    createObject
    (
    () =>
    import('./src/capabilities/create-object.ts', {
    ImportCallOptions.with?: ImportAttributes | undefined
    with
    : {
    type: string
    type
    : 'module-url' },
    }),
    ),
    import AppCapability
    AppCapability
    .
    const appGraphBuilder: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<BuilderExtensions, "org.dxos.app-framework.capability.appGraphBuilder">]>, options?: MakerOptions<readonly [], readonly [], void, any> | undefined) => Module<any>

    Module maker contributing app-graph node builders. Gated by default on the host's idle event rather than the contributing plugin's own start: a builder is what puts a plugin's items in the navtree, so gating it on the plugin's own start (fired when its surface renders) is a deadlock — the item never appears, so it can never be opened. Declare activatesOn to override.

    appGraphBuilder
    (
    () =>
    import('./src/capabilities/app-graph-builder.ts', {
    ImportCallOptions.with?: ImportAttributes | undefined
    with
    : {
    type: string
    type
    : 'module-url' },
    }),
    ),
    import AppCapability
    AppCapability
    .
    const surface: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<any, "org.dxos.app-framework.capability.reactSurface">]>, options?: (MakerOptions<readonly [], readonly [], void, any> & {
    roles?: readonly Role<any>[];
    }) | undefined) => Module<any>

    Module maker contributing React surfaces. Declaring roles (the role tokens the module's surfaces bind, e.g. AppSurface.Article or a plugin's <Plugin>Surface tokens) gates the module on those roles' demand events (

    ActivationEvents.SurfacesRequested

    ) — it loads when a Surface for one of its roles first renders instead of at startup. Modules without declared roles stay eager; an explicit activatesOn wins over the derived gate.

    surface
    (
    () =>
    import('./src/capabilities/react-surface.ts', {
    ImportCallOptions.with?: ImportAttributes | undefined
    with
    : {
    type: string
    type
    : 'module-url' },
    }),
    {
    roles?: readonly Role<any>[] | undefined
    roles
    : [
    import AppSurface
    AppSurface
    .
    const Article: Role<AppSurface.ArticleData<any, {}, unknown>>

    Role token for the article role. Subject defaults to any until narrowed via

    object

    ,

    literal

    , or

    subject

    . Extra passthrough props are permitted.

    Article
    ],
    },
    ),
  4. See the view. Load or reload your plugin below, click New in the header to create a sample link, and select Surface. The inspector renders each of the module’s surfaces whose filter accepts the sample: here, the article.

    Edit the title in the article’s input, and the name in the header changes as you type. In Composer the same view fills the main area when a person opens a link.

  • Read an object reactively in a component and write it with Obj.update.
  • Contribute an article surface for a type.
  • Load a surface module only when its role is needed.

Run it in Composer loads the plugin into Composer from your machine, and gives the assistant Mark as read.