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.
Background
Section titled “Background”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.
-
Sign in to Composer Preview. Open preview.composer.space and sign in or create an identity. Keep the tab open.
-
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 devIt 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. -
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.
-
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.
-
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.
-
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 * asSkill from '@dxos/compute/Skill';import Skillimport * asTemplate from '@dxos/compute/Template';import Templateimport {MarkRead } from '../types/StashedLinkOperation.ts';const MarkRead: Definition<{readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Unknown>;}, void, Service>export constkey = 'com.example.skill.stash';const key: "com.example.skill.stash"export constmake = ():const make: () => Skill.SkillSkill.import SkillSkill =>class SkillSkill 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
keyandversionare stored in the object meta — access them viaObj.getMeta(skill).keyandObj.getMeta(skill).version.Skill.import Skillmake({const make: <"com.example.skill.stash">(props: {key: "com.example.skill.stash";version?: string;name: string;mcpPrompt?: boolean;} & Partial<Skill.Skill>) => Skill.SkillCreate a new Skill. The
key(and optionalversion) are stored in the object meta.keymust be a valid DXN name (e.g.org.dxos.skill.mySkill).key,key: "com.example.skill.stash"name: 'Stash',name: stringtools:tools?: readonly (string & Brand<"ToolId">)[] | undefinedSkill.import SkilltoolDefinitions({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
toolsarray and the session toolkit speak one identifier space.operations: [operations?: readonly Definition.Any[] | undefinedMarkRead] }),const MarkRead: Definition<{readonly link: Ref<StashedLink & OfKind<EntityKind.Object> & Unknown>;}, void, Service>instructions: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;} | undefinedTemplate.import Templatemake({const make: ({ id, source, inputs }?: Template.MakeProps) => Template.Templatesource:source?: string | undefined'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 * asEffect from 'effect/Effect';import Effectimport * asCapability from '@dxos/app-framework/Capability';import Capabilityimport * asAppCapabilities from '@dxos/app-toolkit/AppCapabilities';import AppCapabilitiesimport * asStashSkill from '../skills/StashSkill.ts';import StashSkillexport defaultCapability.import CapabilitymakeModule(() =>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
requiresviayield*(or the ambientCapability.Service/Plugin.Service) - Return an array of typed
Contribution
s (see
contribute
/
contributeAll
)
Effect.import Effectsucceed(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
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) // => 42Capability.import Capabilitycontribute(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)AppCapabilities.import AppCapabilitiesSkillDefinition,const SkillDefinition: Capability.MultiTag<Definition, "org.dxos.app-framework.capability.skillDefinition">StashSkill),import StashSkill),);Add the module to the manifest, then reload Composer to load it.
dx.config.ts import * asAppCapability from '@dxos/app-toolkit/AppCapability';import AppCapability52 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}),),AppCapability.import AppCapabilitysurface(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.Articleor a plugin's<Plugin>Surfacetokens) gates the module on those roles' demand events (ActivationEvents.SurfacesRequested
) — it loads when a
Surfacefor one of its roles first renders instead of at startup. Modules without declared roles stay eager; an explicitactivatesOnwins over the derived gate.() =>import('./src/capabilities/react-surface.ts', {with: {ImportCallOptions.with?: ImportAttributes | undefinedtype: 'module-url' },type: string}),{roles: [roles?: readonly Role<any>[] | undefinedAppSurface.import AppSurfaceArticle],const Article: Role<AppSurface.ArticleData<any, {}, unknown>>Role token for the
articlerole. Subject defaults toanyuntil narrowed viaobject
,
literal
, or
subject
. Extra passthrough props are permitted.
},),AppCapability.import AppCapabilityskillDefinition(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
activatesOnto override.() =>import('./src/capabilities/skill-definition.ts', {with: {ImportCallOptions.with?: ImportAttributes | undefinedtype: 'module-url' },type: string}),), - Access declared
-
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.
What you can do now
Section titled “What you can do now”- 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.