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.
Background
Section titled “Background”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.
-
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 * asEffect from 'effect/Effect';import Effectimport * asCapability from '@dxos/app-framework/Capability';import Capabilityimport * asAppGraphBuilder from '@dxos/app-graph/AppGraphBuilder';import AppGraphBuilderimport * asAppCapabilities from '@dxos/app-toolkit/AppCapabilities';import AppCapabilitiesimport * asAppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher';import AppNodeMatcherimport * asGraphPath from '@dxos/app-toolkit/GraphPath';import GraphPathimport * asTypeSection from '@dxos/app-toolkit/TypeSection';import TypeSectionimport * asOperation from '@dxos/compute/Operation';import Operationimport {Obj,import ObjRef } from '@dxos/echo';import Refimport {StashNamespace } from '../namespace.ts';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}`>;}import {StashedLink } from '../types/StashedLink.ts';class StashedLinkimport {MarkRead } from '../types/StashedLinkOperation.ts';const MarkRead: Operation.Definition<{readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>;}, void, Service> -
Add a section for the type. The navigation tree groups each space’s sections under headings such as Content and System.
TypeSection.createTypeSectionExtensionbuilds 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.urlKeygives 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. */constsection =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.
TypeSection.import TypeSectioncreateTypeSectionExtension(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<...>StashedLink, {class StashedLinkmatch:match?: ((node: Node) => Option<Space>) | undefinedOverride 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.AppNodeMatcher.import AppNodeMatcherwhenNavTreeGroup(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 (notnode.data) so child connectors can access it without an extra graph lookup.GraphPath.import GraphPathGroupTypes.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
typevalues. Co-located withGroupSegments
so plugins can match (via
AppNodeMatcher.whenNavTreeGroup) and create group nodes without depending on plugin-navtree.content),content: "org.dxos.navtree.group.content"groupSegment:groupSegment?: string | undefinedGroup segment the section nests under (e.g.
GraphPath.GroupSegments.ai), whenmatchplaces it beneath a navtree group rather than directly under the space. Included in the forward-resolutionurlPathso section objects atroot/<space>/<groupSegment>/<typename>/<id>resolve deterministically. Omit for a space-direct section (root/<space>/<typename>/<id>).GraphPath.import GraphPathGroupSegments.const GroupSegments: {readonly ai: "ai";readonly content: "content";readonly communications: "communications";readonly crm: "crm";readonly system: "system";readonly debug: "debug";}Navtree section-group node
idvalues, 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.content,content: "content"// The link's address in Composer's URL: `/w/<space>/stash/<id>`.urlKey: 'stash',urlKey: stringRegistered 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'spath-resolution.tsfor how registered keys resolve and serialize URLs.}); -
Attach the operation as an action.
createTypeExtensionmatches every node whose object is a link, and this extension returns an action instead of children. Itsdispositionsays where it shows:toolbarputs it on the link’s toolbar, andlist-itemin the link’s menu in the navigation tree. The command palette lists it either way. Itsdatainvokes 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. */conststashedLinkActions =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.
AppGraphBuilder.import AppGraphBuildercreateTypeExtension({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.
id: 'stashedLinkActions',id: "stashedLinkActions"type:type: typeof StashedLinkStashedLink,class StashedLinkactions: (actions?: ((object: StashedLink & OfKind<EntityKind.Object>, get: AtomContext) => Effect.Effect<Omit<NodeArg<ActionData<any>, Record<string, any>>, "type">[], never, never>) | undefinedlink) =>link: StashedLink & OfKind<EntityKind.Object>Effect.import Effectsucceed([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
Effectthat 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{id: 'markRead',id: stringdata: () =>data: () => Effect.Effect<void, Operation.NoHandlerError, Operation.Service>Operation.import Operationinvoke(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.
MarkRead,const MarkRead: Operation.Definition<{readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>;}, void, Service>{link:link: Ref<StashedLink & OfKind<EntityKind.Object> & Obj.Unknown>Ref.import Refmake(const make: <StashedLink & OfKind<EntityKind.Object>>(object: StashedLink & OfKind<EntityKind.Object>) => Ref<StashedLink & OfKind<EntityKind.Object>>Create a reference to an entity.
link) },link: StashedLink & OfKind<EntityKind.Object>{spaceId:InvokeOptions.spaceId?: SpaceId | undefinedSpace ID to provide database context for the handler.
Obj.import ObjgetDatabase(const getDatabase: (entity: Unknown | Snapshot) => Database | undefinedGet the database the object belongs to. Accepts both reactive objects and snapshots.
link)?.link: StashedLink & OfKind<EntityKind.Object>spaceId },Database.spaceId: SpaceId | undefined),properties: {properties: {label: (string | {ns: "com.example.plugin.stash";})[];icon: string;disposition: string[];}label: ['mark-read.label', {label: (string | {ns: "com.example.plugin.stash";})[]ns:ns: "com.example.plugin.stash"StashNamespace.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}`>;}key }],key: "com.example.plugin.stash"icon: 'ph--check-circle--regular',icon: stringdisposition: ['toolbar', 'list-item'],disposition: string[]},},]),}); -
Contribute the extensions. The module builds both and contributes them together as the App Graph builder capability.
src/capabilities/app-graph-builder.ts export defaultCapability.import CapabilitymakeModule(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
requiresviayield*(or the ambientCapability.Service/Plugin.Service) - Return an array of typed
Contribution
s (see
contribute
/
contributeAll
)
Effect.import EffectfnUntraced(function* () {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)constextensions = yield*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;}>[]]Effect.import Effectall([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
concurrencyto control sequential or concurrent execution. Usemode: "result"to run every effect and collect each success or failure as aResultin the same output shape. Usediscard: trueto ignore successful values and returnvoid.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 failureEffect.succeed("Task3").pipe(Effect.tap(record))])const outcome = await Effect.runPromiseExit(program)const observation = [output, outcome] // => [["Task1"], Exit.fail("Task2: Oh no!")]section,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.
stashedLinkActions]);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.
returnCapability.import Capabilitycontribute(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)AppCapabilities.import AppCapabilitiesAppGraphBuilder,const AppGraphBuilder: Capability.MultiTag<AppGraphBuilder.BuilderExtensions, "org.dxos.app-framework.capability.appGraphBuilder">extensions.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;}>[]]flat(),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.
);}),); - Access declared
-
Add the module to the manifest. The App Graph maker activates the module when the app is idle after startup.
dx.config.ts import * asAppCapability from '@dxos/app-toolkit/AppCapability';import AppCapability37 collapsed linesimport * asAppSurface from '@dxos/app-toolkit/AppSurface';import AppSurfaceimport * asSpaceCapability from '@dxos/plugin-space/SpaceCapability';import SpaceCapabilityimport {StashNamespace } from './src/namespace.ts';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}`>;}constconfig:const config: Plugin.PluginConfig<void>Plugin.import PluginPluginConfig =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.tsdefault export: its config data, runtimeMeta
and factory.
Plugin.import Pluginconfig({const config: <void>({ modules, ...data }: Plugin.ConfigOptions) => Plugin.PluginConfig<void>Defines a plugin entirely from its
dx.config.ts.plugin: {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;}key:key: stringReverse-domain NSID — the plugin's globally-unique key (e.g.
org.dxos.plugin.excalidraw).StashNamespace.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}`>;}key,key: "com.example.plugin.stash"name: 'Stash',name: stringauthor: 'Example',author?: string | undefinedAuthor 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.
description:description?: string | undefined'A reading list: save links with a note, and mark them as read.',icon: {icon?: {readonly key: string;readonly hue?: string | undefined;} | undefinedkey: 'ph--tray-arrow-down--regular',key: stringhue: 'indigo' },hue?: string | undefinedtags: ['labs'],tags?: readonly string[] | undefined},modules: [modules: readonly (Module<any> | undefined)[]Module specs only; each body is a dynamic import, so reading the config loads no plugin code.
AppCapability.import AppCapabilitytranslations(const translations: (resources: Resource | Resource[] | (() => Promise<{translations: Resource | Resource[];}>), options?: {name?: string;environments?: readonly Environment[];}) => Module<void>Module contributing translations.
() => import('./src/translations.ts', {with: {ImportCallOptions.with?: ImportAttributes | undefinedtype: 'module-url' } }),type: string),AppCapability.import AppCapabilityschema(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.
() =>import('./src/capabilities/schema.ts', {with: {ImportCallOptions.with?: ImportAttributes | undefinedtype: 'module-url' },type: string}),),AppCapability.import AppCapabilityoperationHandler(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.Idleto 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
activatesOnnow normalizes to Idle, which would leave the registry incomplete for exactly those boot-path invocations.() =>import('./src/capabilities/operation-handler.ts', {with: {ImportCallOptions.with?: ImportAttributes | undefinedtype: 'module-url' },type: string}),),SpaceCapability.import SpaceCapabilitycreateObject(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
activatesOnto override.() =>import('./src/capabilities/create-object.ts', {with: {ImportCallOptions.with?: ImportAttributes | undefinedtype: 'module-url' },type: string}),),AppCapability.import AppCapabilityappGraphBuilder(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
activatesOnto override.() =>import('./src/capabilities/app-graph-builder.ts', {with: {ImportCallOptions.with?: ImportAttributes | undefinedtype: 'module-url' },type: string}),), -
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.
What you can do now
Section titled “What you can do now”- 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.