Skip to content

Extend the App Graph

By the end of this page each space has a Stashed links section under Content in the navigation tree, and every link has Mark as read on its toolbar and in its menu in the tree.

A PLEX app draws its navigation tree, toolbars and menus from one graph, App Graph. In Composer that is the tree on the left, the object toolbar and the context menus. Its nodes are the things a person can open, and its action nodes are what they can do to them. Plugins add to the graph with builder extensions: functions that match a node and return its children or its actions. The app runs them only for the nodes a person expands. App Graph explains the model.

  1. Start the module’s file. The extensions on this page go in capabilities/app-graph-builder.ts, with these imports.

    src/capabilities/app-graph-builder.ts
    import * as
    import Effect
    Effect
    from 'effect/Effect';
    import * as
    import Capability
    Capability
    from '@dxos/app-framework/Capability';
    import * as
    import AppGraphBuilder
    AppGraphBuilder
    from '@dxos/app-graph/AppGraphBuilder';
    import * as
    import AppCapabilities
    AppCapabilities
    from '@dxos/app-toolkit/AppCapabilities';
    import * as
    import AppNodeMatcher
    AppNodeMatcher
    from '@dxos/app-toolkit/AppNodeMatcher';
    import * as
    import GraphPath
    GraphPath
    from '@dxos/app-toolkit/GraphPath';
    import * as
    import TypeSection
    TypeSection
    from '@dxos/app-toolkit/TypeSection';
    import * as
    import Operation
    Operation
    from '@dxos/compute/Operation';
    import {
    import Obj
    Obj
    ,
    import Ref
    Ref
    } from '@dxos/echo';
    import {
    const StashNamespace: {
    key: "com.example.plugin.stash";
    make: <T>() => <const N extends string>(name: N) => Capability.MultiTag<T, `com.example.plugin.stash.capability.${N}`>;
    makeSingleton: <T>() => <const N extends string>(name: N) => Capability.Tag<T, `com.example.plugin.stash.capability.${N}`>;
    }
    StashNamespace
    } from '../namespace.ts';
    import {
    class StashedLink
    StashedLink
    } from '../types/StashedLink.ts';
    import {
    const MarkRead: Operation.Definition<{
    readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>;
    }, void, Service>
    MarkRead
    } from '../types/StashedLinkOperation.ts';
  2. Add a section for the type. The navigation tree groups each space’s sections under headings such as Content and System. TypeSection.createTypeSectionExtension builds the usual section for an object type: it matches the group you pass, lists the space’s links under a Stashed links heading named and coloured from the type’s annotations, and hides the heading when the space has none. urlKey gives each link an address in the app’s URL, which is how Composer opens one when a person clicks it.

    src/capabilities/app-graph-builder.ts
    /** A "Stashed links" section in each space's Content group, once it has one. */
    const
    const section: Effect.Effect<Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], never, never>

    A "Stashed links" section in each space's Content group, once it has one.

    section
    =
    import TypeSection
    TypeSection
    .
    const createTypeSectionExtension: <typeof StashedLink>(type: typeof StashedLink, options: {
    position?: Position;
    match?: (node: Node) => Option<Space>;
    groupSegment?: string;
    createObject?: (space: Space) => Effect.Effect<any, any, any>;
    urlKey: string;
    sectionUrlKey?: string;
    dropInto?: ((object: StashedLink & OfKind<EntityKind.Object>) => Container) | undefined;
    sort?: ((a: StashedLink & OfKind<EntityKind.Object>, b: StashedLink & OfKind<EntityKind.Object>) => number) | undefined;
    showEmpty?: boolean;
    }) => Effect.Effect<...>
    createTypeSectionExtension
    (
    class StashedLink
    StashedLink
    , {
    match?: ((node: Node) => Option<Space>) | undefined

    Override the default

    AppNodeMatcher.whenSpace

    match function. Use when the section should live under a group node rather than directly under a space. The match must still return Option<Space> so the connector can query the space db.

    match
    :
    import AppNodeMatcher
    AppNodeMatcher
    .
    const whenNavTreeGroup: (groupType: string) => (node: Node) => Option<Space>

    Match navtree section-group nodes of a specific type and return the

    Space

    stored in node.properties.space. Group nodes carry the space in properties (not node.data) so child connectors can access it without an extra graph lookup.

    @example

    GraphBuilder.createExtension({
    id: 'myAiSection',
    match: AppNodeMatcher.whenNavTreeGroup(GraphPath.GroupTypes.ai),
    connector: (space) => Effect.succeed([...]),
    });

    whenNavTreeGroup
    (
    import GraphPath
    GraphPath
    .
    const GroupTypes: {
    readonly ai: "org.dxos.navtree.group.ai";
    readonly content: "org.dxos.navtree.group.content";
    readonly communications: "org.dxos.navtree.group.communications";
    readonly crm: "org.dxos.navtree.group.crm";
    readonly system: "org.dxos.navtree.group.system";
    readonly debug: "org.dxos.navtree.group.debug";
    }

    Navtree section-group node type values. Co-located with

    GroupSegments

    so plugins can match (via AppNodeMatcher.whenNavTreeGroup) and create group nodes without depending on plugin-navtree.

    GroupTypes
    .
    content: "org.dxos.navtree.group.content"
    content
    ),
    groupSegment?: string | undefined

    Group segment the section nests under (e.g. GraphPath.GroupSegments.ai), when match places it beneath a navtree group rather than directly under the space. Included in the forward-resolution urlPath so section objects at root/<space>/<groupSegment>/<typename>/<id> resolve deterministically. Omit for a space-direct section (root/<space>/<typename>/<id>).

    groupSegment
    :
    import GraphPath
    GraphPath
    .
    const GroupSegments: {
    readonly ai: "ai";
    readonly content: "content";
    readonly communications: "communications";
    readonly crm: "crm";
    readonly system: "system";
    readonly debug: "debug";
    }

    Navtree section-group node id values, which double as path segments: a section (collections, database, etc.) is nested under its group node in the graph, so its canonical path includes the group segment. Kept in app-toolkit so any plugin can reference them without depending on plugin-navtree.

    GroupSegments
    .
    content: "content"
    content
    ,
    // The link's address in Composer's URL: `/w/<space>/stash/<id>`.
    urlKey: string

    Registered URL prefix key for this section's connector (e.g. doc, mail). Keys are global, so it is declared here rather than derived — see @dxos/app-graph's path-resolution.ts for how registered keys resolve and serialize URLs.

    urlKey
    : 'stash',
    });
  3. Attach the operation as an action. createTypeExtension matches every node whose object is a link, and this extension returns an action instead of children. Its disposition says where it shows: toolbar puts it on the link’s toolbar, and list-item in the link’s menu in the navigation tree. The command palette lists it either way. Its data invokes Mark as read from Add an operation with a reference to the link, in the link’s space.

    src/capabilities/app-graph-builder.ts
    /** An action on every link, on its toolbar and its menu in the tree. */
    const
    const stashedLinkActions: Effect.Effect<Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], never, never>

    An action on every link, on its toolbar and its menu in the tree.

    stashedLinkActions
    =
    import AppGraphBuilder
    AppGraphBuilder
    .
    const createTypeExtension: <typeof StashedLink, never, "stashedLinkActions">(options: AppGraphBuilder.CreateTypeExtensionOptions<typeof StashedLink, never, "stashedLinkActions">) => Effect.Effect<Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], never, never>

    Create an extension that matches nodes by schema type. The entity type is inferred from the schema type and works for both object and relation schemas. Returns an Effect to allow callbacks to access services via dependency injection.

    createTypeExtension
    ({
    id: "stashedLinkActions"
    id
    : 'stashedLinkActions',
    type: typeof StashedLink
    type
    :
    class StashedLink
    StashedLink
    ,
    actions?: ((object: StashedLink & OfKind<EntityKind.Object>, get: AtomContext) => Effect.Effect<Omit<NodeArg<ActionData<any>, Record<string, any>>, "type">[], never, never>) | undefined
    actions
    : (
    link: StashedLink & OfKind<EntityKind.Object>
    link
    ) =>
    import Effect
    Effect
    .
    const succeed: <{
    id: string;
    data: () => Effect.Effect<void, Operation.NoHandlerError, Operation.Service>;
    properties: {
    label: (string | {
    ns: "com.example.plugin.stash";
    })[];
    icon: string;
    disposition: string[];
    };
    }[]>(value: {
    id: string;
    data: () => Effect.Effect<void, Operation.NoHandlerError, Operation.Service>;
    properties: {
    label: (string | {
    ns: "com.example.plugin.stash";
    })[];
    icon: string;
    disposition: string[];
    };
    }[]) => Effect.Effect<{
    id: string;
    data: () => Effect.Effect<void, Operation.NoHandlerError, Operation.Service>;
    properties: {
    label: (string | {
    ns: "com.example.plugin.stash";
    })[];
    icon: string;
    disposition: string[];
    };
    }[], 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
    ([
    {
    id: string
    id
    : 'markRead',
    data: () => Effect.Effect<void, Operation.NoHandlerError, Operation.Service>
    data
    : () =>
    import Operation
    Operation
    .
    const invoke: <{
    readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>;
    }, void>(op: Operation.Definition<{
    readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>;
    }, void, any>, input: {
    readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>;
    }, options?: Operation.InvokeOptions | undefined) => Effect.Effect<void, Operation.NoHandlerError, Operation.Service>

    Invoke an operation as an Effect. Yields the Operation.Service internally.

    @example

    yield* Operation.invoke(MyOperation, { data: 'test' });

    invoke
    (
    const MarkRead: Operation.Definition<{
    readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>;
    }, void, Service>
    MarkRead
    ,
    {
    link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>
    link
    :
    import Ref
    Ref
    .
    const make: <StashedLink & OfKind<EntityKind.Object>>(object: StashedLink & OfKind<EntityKind.Object>) => Ref<StashedLink & OfKind<EntityKind.Object>>

    Create a reference to an entity.

    @performance ― O(1); allocates a ref that inlines the target, with no lookup.

    make
    (
    link: StashedLink & OfKind<EntityKind.Object>
    link
    ) },
    {
    InvokeOptions.spaceId?: SpaceId | undefined

    Space ID to provide database context for the handler.

    spaceId
    :
    import Obj
    Obj
    .
    const getDatabase: (entity: Unknown | Snapshot) => Database | undefined

    Get the database the object belongs to. Accepts both reactive objects and snapshots.

    @idiom ― org.dxos.echo.objGetDatabase applies: Reaching an object's database — to query, add, or remove — when the surrounding Space is not otherwise needed instead-of: getSpace(obj)?.db (resolving the whole Space just to read its .db) uses: getDatabase

    @performance ― O(1) slot read.

    getDatabase
    (
    link: StashedLink & OfKind<EntityKind.Object>
    link
    )?.
    Database.spaceId: SpaceId | undefined
    spaceId
    },
    ),
    properties: {
    label: (string | {
    ns: "com.example.plugin.stash";
    })[];
    icon: string;
    disposition: string[];
    }
    properties
    : {
    label: (string | {
    ns: "com.example.plugin.stash";
    })[]
    label
    : ['mark-read.label', {
    ns: "com.example.plugin.stash"
    ns
    :
    const StashNamespace: {
    key: "com.example.plugin.stash";
    make: <T>() => <const N extends string>(name: N) => Capability.MultiTag<T, `com.example.plugin.stash.capability.${N}`>;
    makeSingleton: <T>() => <const N extends string>(name: N) => Capability.Tag<T, `com.example.plugin.stash.capability.${N}`>;
    }
    StashNamespace
    .
    key: "com.example.plugin.stash"
    key
    }],
    icon: string
    icon
    : 'ph--check-circle--regular',
    disposition: string[]
    disposition
    : ['toolbar', 'list-item'],
    },
    },
    ]),
    });
  4. Contribute the extensions. The module builds both and contributes them together as the App Graph builder capability.

    src/capabilities/app-graph-builder.ts
    export default
    import Capability
    Capability
    .
    const makeModule: <void, Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.appGraphBuilder", "multi">>, never, never>(fn: (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.appGraphBuilder", "multi">>, never, Scope>) => (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.appGraphBuilder", "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 fnUntraced: <Effect.Effect<[Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[]], never, never>, Capability.Contribution<...>, []>(body: (this: unassigned) => Generator<...>) => () => Effect.Effect<...> (+41 overloads)
    fnUntraced
    (function* () {
    const
    const extensions: [Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[]]
    extensions
    = yield*
    import Effect
    Effect
    .
    const all: <readonly [Effect.Effect<Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], never, never>, Effect.Effect<Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], never, never>], {
    ...;
    }>(arg: readonly [...], options?: {
    ...;
    } | undefined) => Effect.Effect<...>

    Combines an iterable or record of effects into one effect whose success shape follows the input.

    When to use

    Use to run a known collection of effects and collect results in the same tuple, iterable, or record shape.

    Details

    Tuple and iterable inputs collect results in order. Record inputs collect results under the same keys. By default, the combined effect fails on the first failure; with concurrent execution, effects that have already started may be interrupted, while effects not yet started are skipped.

    Options:

    Use concurrency to control sequential or concurrent execution. Use mode: "result" to run every effect and collect each success or failure as a Result in the same output shape. Use discard: true to ignore successful values and return void.

    Example (Collecting tuple results in order)

    import { Effect } from "effect"
    const tupleOfEffects = [
    Effect.succeed(42),
    Effect.succeed("Hello")
    ] as const
    // ┌─── Effect<[number, string], never, never>
    // ▼
    const resultsAsTuple = Effect.all(tupleOfEffects)
    await Effect.runPromise(resultsAsTuple) // => [42, "Hello"]

    Example (Collecting iterable results in order)

    import { Effect } from "effect"
    const iterableOfEffects: Iterable<Effect.Effect<number>> = [1, 2, 3].map(
    Effect.succeed
    )
    // ┌─── Effect<number[], never, never>
    // ▼
    const resultsAsArray = Effect.all(iterableOfEffects)
    await Effect.runPromise(resultsAsArray) // => [1, 2, 3]

    Example (Collecting struct results by key)

    import { Effect } from "effect"
    const structOfEffects = {
    a: Effect.succeed(42),
    b: Effect.succeed("Hello")
    }
    // ┌─── Effect<{ a: number; b: string; }, never, never>
    // ▼
    const resultsAsStruct = Effect.all(structOfEffects)
    await Effect.runPromise(resultsAsStruct) // => { a: 42, b: "Hello" }

    Example (Collecting record results by key)

    import { Effect } from "effect"
    const recordOfEffects: Record<string, Effect.Effect<number>> = {
    key1: Effect.succeed(1),
    key2: Effect.succeed(2)
    }
    // ┌─── Effect<{ [x: string]: number; }, never, never>
    // ▼
    const resultsAsRecord = Effect.all(recordOfEffects)
    await Effect.runPromise(resultsAsRecord) // => { key1: 1, key2: 2 }

    Example (Stopping on the first failure)

    import { Effect, Exit } from "effect"
    const output: Array<unknown> = []
    const record = (value: unknown) => Effect.sync(() => { output.push(value) })
    const program = Effect.all([
    Effect.succeed("Task1").pipe(Effect.tap(record)),
    Effect.fail("Task2: Oh no!").pipe(Effect.tap(record)),
    // Won't execute due to earlier failure
    Effect.succeed("Task3").pipe(Effect.tap(record))
    ])
    const outcome = await Effect.runPromiseExit(program)
    const observation = [output, outcome] // => [["Task1"], Exit.fail("Task2: Oh no!")]

    @see ― forEach for iterating over elements and applying an effect.

    @stability ― stable

    @category ― collecting

    @since ― 2.0.0

    all
    ([
    const section: Effect.Effect<Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], never, never>

    A "Stashed links" section in each space's Content group, once it has one.

    section
    ,
    const stashedLinkActions: Effect.Effect<Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], never, never>

    An action on every link, on its toolbar and its menu in the tree.

    stashedLinkActions
    ]);
    return
    import Capability
    Capability
    .
    const contribute: <Capability.MultiTag<AppGraphBuilder.BuilderExtensions, "org.dxos.app-framework.capability.appGraphBuilder">>(capability: Capability.MultiTag<AppGraphBuilder.BuilderExtensions, "org.dxos.app-framework.capability.appGraphBuilder">, implementation: AppGraphBuilder.BuilderExtensions) => Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.appGraphBuilder", "multi">> (+1 overload)
    contribute
    (
    import AppCapabilities
    AppCapabilities
    .
    const AppGraphBuilder: Capability.MultiTag<AppGraphBuilder.BuilderExtensions, "org.dxos.app-framework.capability.appGraphBuilder">

    @category ― Capability

    AppGraphBuilder
    ,
    const extensions: [Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[]]
    extensions
    .
    Array<Readonly<{ id: string; position?: Position; relation?: RelationInput | undefined; meta?: UrlBinding | undefined; connector?: Connector<...> | undefined; }>[]>.flat<[Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[], Readonly<{
    id: string;
    position?: Position;
    relation?: RelationInput | undefined;
    meta?: AppGraphBuilder.UrlBinding | undefined;
    connector?: Connector<Readonly<{
    id: string;
    type: string;
    cacheable?: string[];
    properties: Readonly<Record<string, any>>;
    data: any;
    }>, NodeArg<any, Record<string, any>>> | undefined;
    }>[]], 1>(this: [...], depth?: 1 | undefined): Readonly<...>[]

    Returns a new array with all sub-array elements concatenated into it recursively up to the specified depth.

    @param ― depth The maximum recursion depth

    flat
    (),
    );
    }),
    );
  5. Add the module to the manifest. The App Graph maker activates the module when the app is idle after startup.

    dx.config.ts
    import * as
    import AppCapability
    AppCapability
    from '@dxos/app-toolkit/AppCapability';
    37 collapsed lines
    import * as
    import AppSurface
    AppSurface
    from '@dxos/app-toolkit/AppSurface';
    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' },
    }),
    ),
  6. See the result. Load or reload your plugin below, create a sample link with New, and select App Graph. The inspector shows the branches of the graph, as Composer builds it, that lead to what your plugin added, and marks those nodes.

    A Stashed links section appears in the space’s Content group, with the link under it, and Mark as read is the link’s action.

  • Add a section for a type to a group in the navigation tree, with an address for each object.
  • Attach an operation to a type’s nodes as an action.

Next, Add a surface gives a link a view to open.