Skip to content

Run it in Composer

By the end of this page Stash runs in Composer: a link opens in the main area, Mark as read runs from its toolbar, and the assistant marks links as read from a chat.

Until now the inspector has loaded the plugin. Composer loads it the same way, from a manifest the plugin’s dev server serves, and gives it the parts the inspector leaves out: a navigation tree, a command palette and the assistant. Only Composer Preview loads plugins from a dev server; composer.space loads only the plugins it ships with.

  1. Sign in to Composer Preview. Open preview.composer.space and sign in or create an identity. Keep the tab open.

  2. Keep the dev server running. It is the one the inspector has loaded from since Create a plugin. If you stopped it, start it again in the plugin’s directory:

    Terminal window
    npm run dev

    It serves the manifest at http://localhost:3967/manifest.json, which points at your unbundled source. If the port is taken, Vite fails rather than moving to another one, since Composer loads from 3967.

  3. Enable the dev plugin. In Composer, click Plugin Settings in the rail and open Plugins. Under Dev Server, leave the manifest URL as it is and click Enable. Allow the browser’s prompt to reach devices on your local network: the plugin’s source comes from your machine.

    Stash appears in the list of plugins, turned on. It stays on until you click Disable, and every reload of Composer loads it again from the dev server, so an edit is one reload away.

  4. Stash a link. Click + on a space and choose Stashed link. The space gets a Stashed links section with the link in it, and the link opens in the main area, in the article from Add a surface. Give it a title, an address and a note.

  5. Mark it as read. Click Mark as read on the link’s toolbar, or run it from the command palette. The article changes to Read; the action is the one Extend the App Graph added.

  6. Offer the operation to the assistant. A skill gives the assistant a set of operations as tools, with instructions on when to use them.

    src/skills/StashSkill.ts
    import * as
    import Skill
    Skill
    from '@dxos/compute/Skill';
    import * as
    import Template
    Template
    from '@dxos/compute/Template';
    import {
    const MarkRead: Definition<{
    readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Unknown>;
    }, void, Service>
    MarkRead
    } from '../types/StashedLinkOperation.ts';
    export const
    const key: "com.example.skill.stash"
    key
    = 'com.example.skill.stash';
    export const
    const make: () => Skill.Skill
    make
    = ():
    import Skill
    Skill
    .
    class Skill

    Skill schema defines the structure for AI assistant skills. Skills contain instructions, tools, and artifacts that guide the AI's behavior. Skills may use tools to create and read artifacts, which are managed by the assistant.

    Follows the Agent Skills spec (https://agentskills.io/specification) as closely as it can, so a skill means the same here as in any other harness. Add a field the spec lacks only with a strong reason, and say what it is. Credentials and MCP servers do not belong here: a server is a connection, which the space owns and each conversation can turn off. Nor do hooks: a plugin contributes those beside its skills (@dxos/compute/Hook).

    The registry key and version are stored in the object meta — access them via Obj.getMeta(skill).key and Obj.getMeta(skill).version.

    Skill
    =>
    import Skill
    Skill
    .
    const make: <"com.example.skill.stash">(props: {
    key: "com.example.skill.stash";
    version?: string;
    name: string;
    mcpPrompt?: boolean;
    } & Partial<Skill.Skill>) => Skill.Skill

    Create a new Skill. The key (and optional version) are stored in the object meta. key must be a valid DXN name (e.g. org.dxos.skill.mySkill).

    make
    ({
    key: "com.example.skill.stash"
    key
    ,
    name: string
    name
    : 'Stash',
    tools?: readonly (string & Brand<"ToolId">)[] | undefined
    tools
    :
    import Skill
    Skill
    .
    const toolDefinitions: ({ tools, operations, }: {
    tools?: readonly string[];
    operations?: readonly Definition<I, O, S = any>.Any[];
    }) => (string & import("effect/Brand").Brand<"ToolId">)[]

    Util to create tool definitions for a skill. Operation tool ids are the model-facing names (see

    Operation.toolName

    ), so the skill's tools array and the session toolkit speak one identifier space.

    toolDefinitions
    ({
    operations?: readonly Definition.Any[] | undefined
    operations
    : [
    const MarkRead: Definition<{
    readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Unknown>;
    }, void, Service>
    MarkRead
    ] }),
    instructions?: {
    readonly source: import("@dxos/echo/internal").Ref<Text & import("@dxos/echo/Entity").OfKind<import("@dxos/echo/internal").EntityKind.Object> & Unknown>;
    readonly inputs?: readonly {
    readonly name: string;
    readonly kind: "operation" | "value";
    readonly default?: any;
    readonly operation?: string | undefined;
    }[] | undefined;
    } | undefined
    instructions
    :
    import Template
    Template
    .
    const make: ({ id, source, inputs }?: Template.MakeProps) => Template.Template
    make
    ({
    source?: string | undefined
    source
    :
    'When the user says they have read a page they stashed, find its ' +
    'link and mark it as read.',
    }),
    });
    src/capabilities/skill-definition.ts
    import * as
    import Effect
    Effect
    from 'effect/Effect';
    import * as
    import Capability
    Capability
    from '@dxos/app-framework/Capability';
    import * as
    import AppCapabilities
    AppCapabilities
    from '@dxos/app-toolkit/AppCapabilities';
    import * as
    import StashSkill
    StashSkill
    from '../skills/StashSkill.ts';
    export default
    import Capability
    Capability
    .
    const makeModule: <void, Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.skillDefinition", "multi">>, never, never>(fn: (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.skillDefinition", "multi">>, never, Scope>) => (props: void) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.skillDefinition", "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.skillDefinition", "multi">>>(value: Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.skillDefinition", "multi">>) => Effect.Effect<Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.skillDefinition", "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<Definition, "org.dxos.app-framework.capability.skillDefinition">>(capability: Capability.MultiTag<Definition, "org.dxos.app-framework.capability.skillDefinition">, implementation: Definition) => Capability.Contribution<Capability.CapabilityIdentifier<"org.dxos.app-framework.capability.skillDefinition", "multi">> (+1 overload)
    contribute
    (
    import AppCapabilities
    AppCapabilities
    .
    const SkillDefinition: Capability.MultiTag<Definition, "org.dxos.app-framework.capability.skillDefinition">

    @category ― Capability

    SkillDefinition
    ,
    import StashSkill
    StashSkill
    ),
    ),
    );

    Add the module to the manifest, then reload Composer to load it.

    dx.config.ts
    import * as
    import AppCapability
    AppCapability
    from '@dxos/app-toolkit/AppCapability';
    52 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' },
    }),
    ),
    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
    ],
    },
    ),
    import AppCapability
    AppCapability
    .
    const skillDefinition: <void, any, readonly [], readonly []>(loader: LoadModule<void, readonly [], readonly [MultiTag<Definition, "org.dxos.app-framework.capability.skillDefinition">]>, options?: MakerOptions<readonly [], readonly [], void, any> | undefined) => Module<any>

    Module maker contributing skill definitions. Gated by default on the assistant plugin's start event — a skill belongs to the assistant feature regardless of which plugin contributes it (skills register into a shared registry whose consumers are reactive); declare activatesOn to override.

    skillDefinition
    (
    () =>
    import('./src/capabilities/skill-definition.ts', {
    ImportCallOptions.with?: ImportAttributes | undefined
    with
    : {
    type: string
    type
    : 'module-url' },
    }),
    ),
  7. Run it from the assistant. Create a second link and give it a title. Open a chat, add the Stash skill, and write “I’ve read” followed by that title. The assistant finds the link, calls the Mark as read tool with a reference to it, and the article changes to Read.

  • Load a plugin into Composer Preview from its dev server.
  • Run a plugin’s action from its toolbar and the command palette.
  • Give the assistant the operation through a skill, and have it run the operation from a chat.

Publishing a plugin puts it in your private registry, so Composer lists it for you on every device.