{link.read ? 'Read' : 'Not read yet'}
{link.read ? 'Read' : 'Not read yet'}
` | Redeem an access code to create an account, via your Atmosphere account or email. |
| `dx account login` | Log in to your DXOS identity. |
| `dx account logout` | Log out of the current profile. |
| `dx registry publish --private` | Build and host the bundle, and list it only to your identity. |
Run any command with `--help` for its full set of options.
---
# Plugins and modules
A plugin is metadata plus modules, and an app is the plugins it loads.
A plugin is metadata plus a set of modules, and a PLEX app is the plugins it loads.
## Model
A plugin's **metadata** names it: a key that no other plugin uses, a display name, a description and an icon. Its
**modules** do the work. Each module activates on an event, runs once, and contributes
[capabilities](/docs/plex/concepts/capabilities/): typed values, such as a surface component or an operation handler,
that other modules look up.
`Plugin.define` takes the metadata, `Plugin.addModule` adds each module, and `Plugin.make` returns a function that the
app's plugin manager calls to create the plugin.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
export const HelloPlugin = Plugin.define(meta).pipe(
Plugin.addModule(GreetingModule),
Plugin.make,
);
```
A plugin you publish describes itself in a `dx.config.ts` **manifest** instead. `Plugin.config` takes the same
metadata, plus registry fields such as tags, and the list of modules. It returns the plugin's `meta` and its `make`
function, so the registry can read the plugin without loading its code.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
import * as AppCapability from '@dxos/app-toolkit/AppCapability';
import { StashNamespace } from './src/namespace.ts';
const config: Plugin.PluginConfig = Plugin.config({
plugin: {
key: StashNamespace.key,
name: 'Stash',
author: 'Example',
description:
'A reading list: save links with a note, and mark them as read.',
icon: { key: 'ph--tray-arrow-down--regular', hue: 'indigo' },
tags: ['labs'],
},
modules: [
// #region translations
AppCapability.translations(
() => import('./src/translations.ts', { with: { type: 'module-url' } }),
),
```
The Stash plugin from the [learning path](/docs/plex/learn/the-plex-plugin/) has seven modules. Each waits on an
event and contributes one capability. Five of them:
## Behavior
- A module is **inline** or **lazy**. `Capability.inlineModule` carries its body with it. `Capability.lazyModule`
names a loader, and the plugin manager imports the body only when the module's event fires. A module whose event
never fires never loads, so a plugin costs nothing until it is used.
```ts
import * as Effect from 'effect/Effect';
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
import * as Capability from '@dxos/app-framework/Capability';
// The module body loads only when its event fires. The loader's default export
// is checked against `provides`.
const LazyGreetingModule = Capability.lazyModule(
'LazyGreeting',
{ provides: [Greeting], activatesOn: ActivationEvents.Idle },
async () => ({
default: Capability.makeModule(() =>
Effect.succeed(Capability.contribute(Greeting, 'Hello, later')),
),
}),
);
```
- The module makers in `AppCapability`, such as `AppCapability.surface`, fix a module's capability and its event, so a
manifest names only the file to load. The loader's default export is checked against the capability at compile time.
- Every module declares what it `provides` and what it `requires`. The plugin manager uses them to order activation;
[Activation](/docs/plex/concepts/activation/) covers the rules.
- Plugins never import each other's modules. A plugin that extends another contributes to a capability the other
reads.
## Limits
- A plugin's key is a reverse-domain name, and its last segment must be camelCase. Keep the key once the plugin has
users: settings and enabled state are stored under it.
- A module keeps no state between activations. What it needs to keep, it contributes or stores.
---
# Capabilities
Typed contributions that modules provide and request, which is how plugins extend each other.
A capability is a typed slot that modules contribute values to and other modules read, so plugins extend each other
without importing each other.
## Model
A capability is a **tag**: an NSID plus the TypeScript type of its values. `Capability.make` declares a multi
capability, which any number of modules contribute to. `Capability.makeSingleton` declares one with exactly one
provider.
```ts
import * as Capability from '@dxos/app-framework/Capability';
export type Exporter = {
readonly format: string;
readonly export: (text: string) => Uint8Array;
};
// A multi capability: any number of modules may contribute one.
export const Exporter = Capability.make()(
'com.example.plugin.export.capability.exporter',
);
// A singleton capability: exactly one module provides it.
export const Clock = Capability.makeSingleton<() => Date>()(
'com.example.plugin.export.capability.clock',
);
```
A module **contributes** by returning `Capability.contribute(tag, value)` from its body, and lists the tag in
`provides`. A module **requests** with `yield*` on the tag, or with `Capability.getAll` for every contribution to a
multi capability, and lists what it cannot run without in `requires`.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
export const ExportMenuModule = Capability.inlineModule(
'ExportMenu',
{ requires: [Clock], provides: [] },
() =>
Effect.gen(function* () {
const now = yield* Clock;
const exporters = yield* Capability.getAll(Exporter);
console.log(
now(),
exporters.map((exporter) => exporter.format),
);
}),
);
```
The capability manager holds every contribution. It is the `Capability.Service` in a module's Effect context, and the
plugin manager owns it.
## Behavior
- The compiler checks a module's body against its `provides`: a body that returns a contribution the module does not
list does not compile.
- The plugin manager activates a module only after every capability in its `requires` has a contributor, so a module
can read them without waiting.
- Four ways to read a capability suit different needs:
| Call | Returns | When |
| --- | --- | --- |
| `Capability.get` | The first contribution, or fails with `CapabilityNotFoundError` | The capability must be there. |
| `Capability.getAll` | Every contribution, possibly none | A multi capability. |
| `Capability.waitFor` | The first contribution, once one arrives | It is contributed later than this module runs. |
| `Capability.atom` | An atom of every contribution | Reactive code that should update as plugins come and go. |
```ts
import * as Effect from 'effect/Effect';
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
import * as Capability from '@dxos/app-framework/Capability';
import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
// `waitFor` suspends until some module contributes the capability, so this
// module can activate on Startup even though App Graph arrives later.
export const GraphReadyModule = Capability.inlineModule(
'GraphReady',
{ provides: [], activatesOn: ActivationEvents.Startup },
() =>
Effect.gen(function* () {
const graph = yield* Capability.waitFor(AppCapabilities.AppGraph);
// An atom of every contribution, for reactive code.
const schemas = yield* Capability.atom(AppCapabilities.Schema);
console.log(typeof graph, schemas);
}),
);
```
- One plugin extends another by contributing to a capability the other reads. Stash adds itself to the Space
plugin's create menu this way: the Space plugin declares `SpaceCapabilities.CreateObjectEntry` and lists every
contribution in its menu, and Stash contributes one entry.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
import * as Operation from '@dxos/compute/Operation';
import { Type } from '@dxos/echo';
import * as SpaceCapabilities from '@dxos/plugin-space/SpaceCapabilities';
import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation';
import { StashedLink, make } from '../types/StashedLink.ts';
export default Capability.makeModule(() =>
Effect.succeed(
Capability.contribute(SpaceCapabilities.CreateObjectEntry, {
id: Type.getTypename(StashedLink),
createObject: (props, options) =>
Operation.invoke(
SpaceOperation.AddObject,
{ object: make(props), target: options.target },
{ spaceId: options.db.spaceId },
),
}),
),
);
```
- Contributions change after startup, when a module activates late or a person turns a plugin on or off.
`Capability.contributions` and `Capability.atom` stay current; a value read once with `getAll` does not.
- When a module deactivates, its contributions are removed and its finalizers run.
## Limits
- A singleton capability with two providers is an error. Use a multi capability for anything more than one plugin
might contribute.
- The capability manager does not check a value against anything but its TypeScript type. Two plugins that build
against different versions of a capability's type are not caught at runtime.
- Capabilities are in-process. They do not cross from the app to a worker or another device.
---
# Activation
When a module runs, the events that activate it, and the order the plugin manager follows.
Activation is the plugin manager running a module's body: once, when the module's event has fired and every
capability it requires has been contributed.
## Model
An **activation event** is a named moment in the app's life, such as startup or the first time an article is shown.
A module names the event it waits for in `activatesOn`, and can combine several: `ActivationEvent.oneOf` activates on
the first of them, and `ActivationEvent.allOf` waits for all of them.
```ts
import * as Effect from 'effect/Effect';
import * as ActivationEvent from '@dxos/app-framework/ActivationEvent';
import * as Capability from '@dxos/app-framework/Capability';
export const IndexModule = Capability.inlineModule(
'Index',
{
provides: [Index],
activatesOn: ActivationEvent.oneOf(IndexRequested, SearchStart),
},
() => Effect.succeed(Capability.contribute(Index, new Map())),
);
```
A module's `requires` is the second gate. The **plugin manager** keeps a graph of which module provides each
capability, and activates providers before the modules that require them, whichever event activated the provider.
Registration order plays no part.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
// No `activatesOn`: this module waits for `Index`, so it activates whenever the
// module above does.
export const QueryModule = Capability.inlineModule(
'Query',
{ requires: [Index], provides: [] },
() =>
Effect.gen(function* () {
const index = yield* Index;
console.log(index.size);
}),
);
```
## Behavior
- The app fires `ActivationEvents.Startup` while it boots and `ActivationEvents.Idle` once it has painted and the
browser is idle. A module with no `activatesOn` belongs to the Idle wave, so only what the first screen needs runs
at startup.
- The framework fires further events when something first needs a module. `ActivationEvents.SurfacesRequested(role)`
fires the first time a surface of that role renders; `CommandsRequested` fires when the command palette or CLI asks
for commands. The module makers in `AppCapability` choose the event, so the surface module of a plugin loads with
the first article, not at startup.
- A module can fire an event itself with `Plugin.activate`, which is how one plugin loads another's modules on demand.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
export const requestIndex = Plugin.activate(IndexRequested);
```
- An event stays fired. A module added after its event, for example by a plugin a person turns on later, activates
at once.
- A lazy module's code is imported only when it is about to activate. A module whose event never fires is never
loaded, which is what keeps a large app's boot small.
- Plugins come and go while the app runs. Enabling one registers its modules and activates every module whose event
has fired; disabling one deactivates its modules in reverse order, runs their finalizers and removes their
contributions. [PluginManager](/docs/plex/guides/plugin-manager/) shows the calls.
- A module that fails, or does not finish within 30 seconds, is recorded on the manager's `failed` list and its
plugin is disabled, so one broken plugin does not stop the app from starting.
## Limits
- Two modules that require each other's capabilities form a cycle, and the plugin manager fails them with
`DependencyCycleError` at runtime.
- A module that requires a capability no enabled plugin provides waits forever, without an error. The
[inspector](/docs/plex/guides/debugging/) shows such a module as waiting.
- Activation order is a graph, not a sequence. Do not rely on two modules with no requirement between them
activating in a particular order.
---
# Operations
Named, typed actions that the UI, the command palette and agents all run the same way.
An operation is an action with a name, a typed input and a typed output, defined once and run from anywhere in an
app, by people and by agents.
## Model
An operation **definition** has a key (a DXN), metadata for people and agents, and schemas for its input and output. A
**handler** is an Effect that takes the input and returns the output. Definitions and handlers are separate values, so a
plugin can export a definition for other plugins to call without exposing how it runs.
```ts
import * as Schema from 'effect/Schema';
import * as Operation from '@dxos/compute/Operation';
import { Database, DXN, Ref } from '@dxos/echo';
import { StashedLink } from './StashedLink.ts';
export const MarkRead = Operation.make({
meta: {
key: DXN.make('com.example.operation.stashedLink.markRead'),
name: 'Mark as read',
description: 'Mark a link as read.',
icon: 'ph--check-circle--regular',
},
// The handler resolves the reference in the link's space.
services: [Database.Service],
input: Schema.Struct({
link: Ref.Ref(StashedLink).annotate({
description: 'The link that was read.',
}),
}),
output: Schema.Void,
});
```
Plugins contribute handlers in an **operation handler set** through the `OperationHandler` capability. The **operation
invoker** looks up the handler by the definition's key and runs it. Any module, handler or graph action can invoke an
operation with `Operation.invoke`; a component uses `useOperation`.
```ts
import * as Effect from 'effect/Effect';
import * as Operation from '@dxos/compute/Operation';
import { Ref } from '@dxos/echo';
import { type StashedLink } from './src/types/StashedLink.ts';
import { MarkRead } from './src/types/StashedLinkOperation.ts';
// Any module, handler or action can run an operation by its definition.
export const markRead = (link: StashedLink) =>
Operation.invoke(MarkRead, { link: Ref.make(link) }).pipe(
Effect.catchTag('NoHandlerError', () => Effect.succeed(undefined)),
);
```
A **skill** hands operations to the assistant. It is a name, instructions, and a list of operations that become the
assistant's tools; a plugin contributes it through `AppCapabilities.SkillDefinition`.
```ts
import * as Skill from '@dxos/compute/Skill';
import * as Template from '@dxos/compute/Template';
import { MarkRead } from '../types/StashedLinkOperation.ts';
export const key = 'com.example.skill.stash';
export const make = (): Skill.Skill =>
Skill.make({
key,
name: 'Stash',
tools: Skill.toolDefinitions({ operations: [MarkRead] }),
instructions: Template.make({
source:
'When the user says they have read a page they stashed, find its ' +
'link and mark it as read.',
}),
});
```
Stash's Operations module in the inspector runs Mark as read from a form built from its input schema:
## Behavior
- One definition serves three callers. A button invokes it through a hook. The command palette lists it. A skill hands
it to the assistant as a tool, and the assistant reads the schema descriptions to fill in the input. None of the three
needs code of its own.
- The invoker validates the input against the schema before the handler runs, and the output after.
- Errors are typed. A handler fails with tagged errors that a caller catches by tag, and invoking an operation that no
plugin handles fails with `NoHandlerError`.
- An operation can be undone. A plugin contributes an `UndoMapping` that names the operation's inverse and derives the
inverse's input from the forward input and output; the app records each undoable invocation and offers to undo it.
```ts
import * as Effect from 'effect/Effect';
import * as Schema from 'effect/Schema';
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
import * as Capabilities from '@dxos/app-framework/Capabilities';
import * as Capability from '@dxos/app-framework/Capability';
import * as Operation from '@dxos/compute/Operation';
import { DXN } from '@dxos/echo';
const Rename = Operation.make({
meta: {
key: DXN.make('com.example.operation.rename'),
name: 'Rename',
},
input: Schema.Struct({ id: Schema.String, name: Schema.String }),
output: Schema.Struct({ previous: Schema.String }),
});
// The inverse is the same operation with the previous name.
export const RenameUndoModule = Capability.inlineModule(
'RenameUndo',
{
provides: [Capabilities.UndoMapping],
activatesOn: ActivationEvents.Startup,
},
() =>
Effect.succeed(
Capability.contribute(Capabilities.UndoMapping, [
{
operation: Rename,
inverse: Rename,
deriveContext: (input, output) => ({
id: input.id,
name: output.previous,
}),
message: 'Renamed',
},
]),
),
);
```
- Handlers load lazily. A lazy handler set names each handler's module, and the framework imports it the first time the
operation is invoked.
- The App Toolkit defines operations every plugin can call. `LayoutOperation.Open` opens items by path,
`LayoutOperation.Close` closes them, and `SpaceOperation.AddObject` adds an object to a space.
## Limits
- A handler runs with the services its definition declares, plus the operation service for invoking other operations.
It does not get the React tree.
- An operation's input must be serializable for the assistant to call it. Pass objects by reference where the caller is
an agent.
- Undo replays the inverse operation; it does not restore a snapshot. An operation whose effect cannot be inverted
from its input and output is not undoable.
---
# Surfaces
How the framework decides which component renders an object in a given place.
A surface is a named place in an app's UI that plugins fill with components. The host asks for "the article for this
object" and does not know which plugin will answer.
## Model
A **role** names a place: `Article` is the main content area, `Section` is an object inside a stack, `CardContent` is
the body of a card, and `StatusIndicator` is the status bar. Each role declares the shape of the data it passes, such as
`{ subject, attendableId }` for an article.
The roles a plugin fills most often, such as `Article` for the main content area, come from `AppSurface` in
`@dxos/app-toolkit`. [App capabilities](/docs/plex/guides/app-capabilities/#surface-roles) lists them.
[Surface](/docs/plex/guides/surface/) lists the rest and shows how to narrow a role's `subject` to one type.
A **surface contribution** is a component plus a **filter**. The filter names one or more roles and a guard on the data,
for example "the article role, when the subject is a Note". Plugins contribute surfaces through the `ReactSurface`
capability.
```ts
import * as Surface from '@dxos/app-framework/Surface';
import * as AppSurface from '@dxos/app-toolkit/AppSurface';
export const NoteArticleSurface = Surface.create({
id: 'article',
// Matches the article role when the data's subject is a Note.
filter: AppSurface.object(AppSurface.Article, Note),
component: NoteArticle,
// Maps the surface's data onto the component's props; checked against the
// filter's data type.
props: ({ role, data: { subject, attendableId } }) => ({
role,
subject,
attendableId,
}),
});
```
Stash's Surface module in the inspector renders its article with the sample link:
## Behavior
- The host renders ` `. The framework collects every contribution whose filter
names that role and whose guard accepts the data.
- Contributions are ordered by their `position`, then by plugin order. The host renders the first one, or the first
`limit` of them when it wants several, as a toolbar does.
- A filter can match several roles at once with `AppSurface.oneOf`, or require two conditions with `AppSurface.allOf`.
- A component can render another plugin's surface by rendering `Surface.Surface` itself. This is how a stack shows
each object with whatever plugin owns its type, and how a markdown document embeds a table.
```ts
import * as Surface from '@dxos/app-framework/Surface';
import * as AppSurface from '@dxos/app-toolkit/AppSurface';
// Renders whatever surface another plugin contributed for this object in the
// section role.
export const Embedded = ({ subject }: { subject: Note }) => (
);
```
- A surface module loads lazily. The manifest lists the roles the module serves, and the plugin manager imports the module the
first time one of those roles is requested.
## Limits
- A surface component gets only the data its role passes. It must not assume which container it is in, how wide it is,
or that it is the only surface on screen; the same article can show in a plank, a stack or a dialog.
- The `props` mapper of a surface runs during the host's render, so it cannot call hooks. A component that needs hooks
is its own component.
- A surface does not own the object it renders. Other surfaces, devices and agents edit the same object, so the
component reads it reactively.
---
# Effect
Use Effect services and layers from a plugin's modules and operations.
A module's body and an operation's handler are Effects. A plugin can read the capability manager as an Effect
service, contribute layers of its own, and name the services an operation needs.
## Read capabilities as a service
Inside a module, the capability manager is `Capability.Service`. `Capability.get`, `getAll` and `waitFor` read it
for you; yield the service itself when you need the manager.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
// Inside a module, the capability manager is the `Capability.Service` in
// context; `Capability.get` and friends read it.
export const CountSchemasModule = Capability.makeModule(() =>
Effect.gen(function* () {
const manager = yield* Capability.Service;
const schemas = manager.getAll(AppCapabilities.Schema).flat();
console.log(schemas.length);
return [];
}),
);
```
## Contribute a layer
A service an operation uses comes from a layer. Contribute a `LayerSpec` to `Capabilities.LayerSpec`: it says what
the layer provides, what it requires and how widely one instance is shared. With `affinity: 'application'` the app
builds the layer once and every operation shares it.
```ts
import * as Context from 'effect/Context';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';
import * as Capabilities from '@dxos/app-framework/Capabilities';
import * as Capability from '@dxos/app-framework/Capability';
import * as LayerSpec from '@dxos/compute/LayerSpec';
export class LinkChecker extends Context.Service<
LinkChecker,
{ readonly check: (url: string) => Effect.Effect }
>()('com.example.plugin.stash.LinkChecker') {}
const LinkCheckerLive = Layer.succeed(LinkChecker, {
check: (url) => Effect.succeed(url.startsWith('https://')),
});
// A layer spec says what the layer provides and how widely it is shared; the
// runtime builds it for any operation that declares the service.
export const LinkCheckerModule = Capability.makeModule(() =>
Effect.succeed(
Capability.contribute(
Capabilities.LayerSpec,
LayerSpec.make(
{ affinity: 'application', requires: [], provides: [LinkChecker] },
() => LinkCheckerLive,
),
),
),
);
```
## Declare an operation's services
An operation lists the services it needs in `services`. When it is invoked, the runtime builds them from the layer
specs plugins contributed, and the handler yields them like any Effect service.
```ts
import * as Effect from 'effect/Effect';
import * as Schema from 'effect/Schema';
import * as Operation from '@dxos/compute/Operation';
import { DXN } from '@dxos/echo';
export const CheckLink = Operation.make({
meta: {
key: DXN.make('com.example.operation.checkLink'),
name: 'Check link',
},
input: Schema.Struct({ url: Schema.String }),
output: Schema.Struct({ ok: Schema.Boolean }),
services: [LinkChecker],
});
export const checkLink = CheckLink.pipe(
Operation.withHandler(({ url }) =>
Effect.gen(function* () {
const checker = yield* LinkChecker;
return { ok: yield* checker.check(url) };
}),
),
);
```
An operation whose services have no layer fails when invoked, not when the plugin loads. [Testing a
plugin](/docs/plex/guides/testing/) catches it.
## Limits
- Never run an Effect with `Effect.runPromise` inside a module. Return it, so the plugin manager can interrupt it
when the module deactivates.
- A layer spec's service is in-process. A service that must run on EDGE needs a layer there too.
---
# Testing
Test a plugin's modules without an app, with the test harness.
```ts
import * as Harness from '@dxos/plugin-testing/Harness';
```
A plugin's modules run in a plugin manager, so a test starts one. `Harness.createComposerTestApp` starts a headless
app with the core plugins Composer gives every plugin, plus the plugins under test. It runs on an in-memory client,
so it needs no browser, account or server.
## Start the app
```ts
import * as Harness from '@dxos/plugin-testing/Harness';
import { make as makePlugin } from './dx.config.ts';
// A headless app with the core plugins Composer gives a plugin, plus this one.
await using harness = await Harness.createComposerTestApp({
plugins: [makePlugin()],
});
```
`await using` closes the app when the test ends, which deactivates every module and runs its finalizers.
## Fire activation events
The harness fires the startup events. A module gated on a later event stays inactive until the test fires that event,
so a test can check what a module does before and after it activates.
```ts
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
// Startup has fired; modules gated on later events wait until the test fires them.
await harness.fire(ActivationEvents.Idle);
const builders = harness.getAll(AppCapabilities.AppGraphBuilder);
console.log(builders.length > 0); // true
```
## Read capabilities
`getAll` returns every contribution to a capability, as `Capability.getAll` does inside a module. `waitForCapability`
waits for a capability whose module has not activated yet.
```ts
import * as Capabilities from '@dxos/app-framework/Capabilities';
import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
const schemas = harness.getAll(AppCapabilities.Schema).flat();
const handlers = await harness.waitForCapability(Capabilities.OperationHandler);
console.log(schemas.length, typeof handlers);
```
## Invoke an operation
`invoke` runs an operation through the app's invoker, with the handlers the plugins contributed, and returns its
output. A typed error fails the promise.
```ts
import { Ref } from '@dxos/echo';
import { make } from './src/types/StashedLink.ts';
import { MarkRead } from './src/types/StashedLinkOperation.ts';
const link = make({ title: 'DXOS', url: 'https://dxos.org' });
await harness.invoke(MarkRead, { link: Ref.make(link) });
console.log(link.read);
```
## Limits
- The harness renders no UI. Test a surface's component on its own, with a story or a component test.
- Plugins that need a real network or device, such as a connector, need their own test layers.
---
# Debugging
Find out why a module did not activate or a contribution did not show.
Most plugin bugs are one of three things: the plugin did not load, a module did not activate, or a contribution
reached the app but did not show where you expected. Start with the inspector, which shows all three.
## Load the plugin into the inspector
Start the plugin's dev server in its folder with `npm run dev`, then click **Load dev plugin** below. The inspector
loads the plugin from `http://localhost:3967/manifest.json`, the same way Composer does.
The sidebar lists each module with its status:
- **Active:** the module ran and its contributions are in the app.
- **Waiting:** its activation event has not fired yet. Check that the module lists the event you meant.
- **Failed:** the module threw. Select it to read the error.
## Load errors
When the plugin does not load at all, the inspector names the reason:
| Error | Meaning |
| --- | --- |
| Couldn't reach the manifest | The dev server is not running, is on another port, or the browser blocked the request to your local network. |
| Not a plugin manifest | Something answered on port 3967, but `vite.config.ts` does not add `composerPlugin`. |
| The plugin's code failed to load | A module threw while importing. The message is the module's own error. |
| Metadata does not match | The key in `dx.config.ts` differs from the one the plugin's code declares. |
## Debug in Composer Preview
Load the plugin into [Composer Preview](https://preview.composer.space) as on
[Run it in Composer](/docs/plex/learn/run-in-composer/), then open the browser's devtools. The plugin's source is
served unbundled, so its files appear under `localhost:3967` in the Sources panel, and breakpoints and `console.log`
work as in any Vite app. A reload of Composer loads your latest edit.
## Limits
- The inspector shows one plugin. A bug that depends on another plugin, such as a surface another plugin's surface
outranks, shows only in Composer.
- Only Composer Preview loads dev plugins; composer.space does not.
---
# App capabilities
The capabilities a workspace app such as Composer adds for plugins to contribute, from the App Toolkit.
```ts
import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
import * as AppCapability from '@dxos/app-toolkit/AppCapability';
import * as AppSurface from '@dxos/app-toolkit/AppSurface';
import * as UiHooks from '@dxos/react-ui/Hooks';
```
The framework defines how plugins extend each other, but not what an app is made of. `@dxos/app-toolkit` adds that
for a workspace app such as Composer: a layout, App Graph, settings, translations, the standard surface roles. Its
capabilities are in `AppCapabilities`, and Composer and any other app built with the toolkit read them. `AppCapability` has a module
maker for most of them, which sets the activation event and environments so a manifest only names the file.
## A tour, by group
Most plugins touch a handful of these. The rest exist for the plugins that build the app's own features, and a
plugin that is not one of those can ignore them.
- **Layout.** `Layout` is the current layout as an atom: what is open, the sidebar, the mode. Plugins read it; the
layout plugin provides it. Changing it goes through `LayoutOperation`.
- **App Graph.** `AppGraphBuilder` adds nodes and actions, `AppGraph` is the graph itself for reading, and
`AppGraphRetention` keeps nodes loaded while they are off screen. [App Graph](/docs/plex/guides/app-graph/) covers
them.
- **Settings and text.** `Settings` adds a section to the settings page, and `Translations` gives the plugin's
strings their text. [Settings and state](/docs/plex/guides/settings-and-state/) covers settings, and
[Translations](#translations) below covers text.
- **Schema.** `Schema` registers the ECHO types the app can store, query and name.
[Using ECHO in a plugin](/docs/plex/guides/using-echo/) covers it.
- **Agents.** `SkillDefinition` gives the assistant a skill, `Toolkit` adds tools outside a skill, and `McpServer`
exposes a built-in MCP server.
- **Files and content.** `FileUploader` stores an uploaded file, `Exporter` adds an export format, `TextContent`
extracts an object's text for search and agents, and `PluginAsset` publishes a file other tools can read, such as
the plugin's spec.
| Capability | Maker | What it adds |
| --- | --- | --- |
| `AppGraph` | none | The graph itself, for reading. |
| `AppGraphBuilder` | `appGraphBuilder` | Nodes and actions in App Graph. |
| `AppGraphRetention` | none | Nodes App Graph keeps loaded. |
| `Exporter` | `exporter` | An export format for objects. |
| `FileUploader` | none | Storage for files people add. |
| `Layout` | none | The current layout, as an atom. |
| `McpServer` | `mcpServer` | A built-in MCP server. |
| `PluginAsset` | `pluginAsset` | A file other tools can read, such as the plugin's spec. |
| `Schema` | `schema` | ECHO types the app can store, query and name. |
| `Settings` | `settings` | A section in the settings page. |
| `SkillDefinition` | `skillDefinition` | A skill the assistant can use. |
| `SpaceTemplate` | `spaceTemplates` | A template for new spaces. |
| `TextContent` | `textContent` | The text of an object, for search and agents. |
| `Toolkit` | none | Tools for the assistant outside a skill. |
| `Translations` | `translations` | Text for the plugin's strings and type labels. |
`AppCapability.surface` and `AppCapability.operationHandler` make modules for the framework's `ReactSurface` and
`OperationHandler` capabilities.
## Translations
A plugin never writes its UI text inline. It contributes translation resources, keyed by language and namespace, and
reads strings by key, so the app can show them in the person's language and other plugins can label its objects.
### Contribute translation resources
A resource maps a language to namespaces, and each namespace to keys and their text. Use the plugin's key as the
namespace for its own strings, and a type's typename for the strings that describe the type.
`AppCapability.translations` contributes the resources; it activates at startup, since labels are needed for the
first screen.
```ts
import { Type } from '@dxos/echo';
import { StashNamespace } from './namespace.ts';
import { StashedLink } from './types/StashedLink.ts';
export const translations = [
{
'en-US': {
[StashNamespace.key]: {
'plugin.name': 'Stash',
'mark-read.label': 'Mark as read',
},
// #region type
[Type.getTypename(StashedLink)]: {
'typename.label': 'Stashed link',
'typename.label_zero': 'Stashed links',
'typename.label_one': 'Stashed link',
'typename.label_other': 'Stashed links',
'object-name.placeholder': 'New link',
'add-object.label': 'Add link',
},
```
```ts
AppCapability.translations(
() => import('./src/translations.ts', { with: { type: 'module-url' } }),
),
```
### The keys the app reads for a type
The app labels objects of a type from that type's namespace, so a type with these keys reads well in every menu:
| Key | Where it shows |
| --- | --- |
| `typename.label` | The type's name, such as in the create menu. |
| `typename.label_zero`, `_one`, `_other` | The plural forms, such as a section heading. |
| `object-name.placeholder` | The name of a new object before the person names it. |
| `add-object.label` | The create menu's entry for the type. |
### Read a string from a component
`useTranslation` takes a namespace and returns `t`. A key with a `count` picks its plural form.
```ts
import * as UiHooks from '@dxos/react-ui/Hooks';
import { StashNamespace } from './src/namespace.ts';
export const PluginTitle = () => {
// Strings resolve in the plugin's namespace; the app picks the language.
const { t } = UiHooks.useTranslation(StashNamespace.key);
return {t('plugin.name')}
;
};
```
```ts
import * as UiHooks from '@dxos/react-ui/Hooks';
export const LinkCount = ({ count }: { count: number }) => {
const { t } = UiHooks.useTranslation('com.example.type.stashedLink');
// `count` picks `typename.label_zero`, `_one` or `_other`.
return {`${count} ${t('typename.label', { count })}`};
};
```
App Graph nodes and actions take a label as a key and namespace, `['mark-read.label', { ns }]`, and the app
translates it where it renders.
## Surface roles
The roles a plugin fills most often are in `AppSurface` from `@dxos/app-toolkit`:
| Role | Where it renders | What it is given |
| --- | --- | --- |
| `Article` | The main content area, one object at a time | `subject`, `attendableId` |
| `Section` | An object inside a stack of objects | `subject`, `attendableId` |
| `CardContent` | The body of a card, in search results and boards | `subject` |
| `ObjectProperties` | The object's properties panel | `subject` |
| `Dialog`, `Popover` | A dialog or popover a plugin opens by name | `component`, `props` |
| `Navigation` | The navigation sidebar | `current`, the open item's id |
| `StatusIndicator` | The status bar | no data |
A plugin can render into any role another plugin defines, and declare its own. See
[Surfaces](/docs/plex/concepts/surfaces/).
## Contribute settings, metadata and schema registrations
A settings contribution names a prefix, a schema and a writable atom. The app renders a form from the schema and writes
the person's changes to the atom.
```ts
import * as Effect from 'effect/Effect';
import * as Atom from 'effect/reactivity/Atom';
import * as Schema from 'effect/Schema';
import * as Struct from 'effect/Struct';
import * as Capability from '@dxos/app-framework/Capability';
import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
export const Settings = Schema.Struct({
showSeconds: Schema.optional(
Schema.Boolean.annotate({ title: 'Show seconds' }),
),
}).mapFields(Struct.map(Schema.mutableKey));
export interface Settings extends Schema.Schema.Type {}
export const SettingsModule = Capability.makeModule(() =>
Effect.sync(() => {
// A writable atom. Real plugins persist it with `KvsStore.make` from
// `@dxos/effect`.
const atom = Atom.make({ showSeconds: false });
return Capability.contribute(AppCapabilities.Settings, {
prefix: ClockNamespace.key,
schema: Settings,
atom,
});
}),
);
```
In a manifest, each maker takes the loader of the module that contributes the value:
```ts
import * as AppCapability from '@dxos/app-toolkit/AppCapability';
// Each maker declares the capability and its default activation event, so a
// manifest names only the loader.
export const modules = [
AppCapability.settings(async () => ({ default: SettingsModule })),
AppCapability.translations([
{ 'en-US': { [ClockNamespace.key]: { 'plugin.name': 'Clock' } } },
]),
];
```
The tutorial plugin registers its type with `AppCapability.schema`, whose module's default export is the list of types:
```ts
import { StashedLink } from '../types/StashedLink.ts';
export default [StashedLink];
```
## App capability or a new one
Use an app capability when you are adding to something the app already shows: a setting, a node, a skill. Declare a
new capability when your plugin is the host and other plugins add to it, the way the Space plugin declares
`SpaceCapabilities.CreateObjectEntry` for the types in its create menu. [Capability](/docs/plex/guides/capability/)
shows how.
## Related
| Page | What it covers |
| --- | --- |
| [Capability](/docs/plex/guides/capability/) | Declaring and reading capabilities. |
| [Plugin](/docs/plex/guides/plugin/) | Modules and makers. |
| [React](/docs/plex/guides/react/) | The other hooks a plugin component uses. |
---
# Settings and state
Keep a plugin's settings, which sync, apart from its device state, which does not.
A plugin keeps two kinds of value outside ECHO:
- **Settings** are choices the person makes, such as a default view. They follow the person to every device.
- **State** belongs to one device, such as the last item opened or a panel's width. It does not sync.
Keep them apart. A device-only value stored as a setting moves to the person's other devices, where it is wrong.
## Settings
Describe the settings with a schema, and contribute it with `AppCapabilities.Settings`. The app renders a form for
it on the settings page, using each field's `title`, and syncs the values. `KvsStore.make` gives an atom that holds
the values, with defaults for a first run.
```ts
import * as Effect from 'effect/Effect';
import * as Schema from 'effect/Schema';
import * as Struct from 'effect/Struct';
import * as Capability from '@dxos/app-framework/Capability';
import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
import * as KvsStore from '@dxos/effect/KvsStore';
import { StashNamespace } from './src/namespace.ts';
export const Settings = Schema.Struct({
openInNewTab: Schema.optional(
Schema.Boolean.annotate({ title: 'Open links in a new tab' }),
),
}).mapFields(Struct.map(Schema.mutableKey));
// The app renders a form from the schema and syncs the values to the person's
// other devices.
export const SettingsModule = Capability.makeModule(() =>
Effect.sync(() =>
Capability.contribute(AppCapabilities.Settings, {
prefix: StashNamespace.key,
schema: Settings,
atom: KvsStore.make({
key: StashNamespace.key,
schema: Settings,
defaultValue: () => ({ openInNewTab: true }),
}),
}),
),
);
```
## State
Device state is the plugin's own capability. Declare a singleton on the plugin's namespace and store it under the
key `${key}.state`, so it stays on this device.
```ts
import * as Effect from 'effect/Effect';
import * as Schema from 'effect/Schema';
import * as Struct from 'effect/Struct';
import * as Capability from '@dxos/app-framework/Capability';
import * as KvsStore from '@dxos/effect/KvsStore';
import { StashNamespace } from './src/namespace.ts';
export const State = Schema.Struct({
lastOpened: Schema.optional(Schema.String),
}).mapFields(Struct.map(Schema.mutableKey));
// Device state is the plugin's own capability, stored under its own key.
export const StateCapability =
StashNamespace.makeSingleton>()('state');
const makeState = () =>
KvsStore.make({
key: `${StashNamespace.key}.state`,
schema: State,
defaultValue: () => ({}),
});
export const StateModule = Capability.makeModule(() =>
Effect.sync(() => Capability.contribute(StateCapability, makeState())),
);
```
## Read and write them
Both are atoms. A component reads one with `useAtomValue` and writes it with `useAtomSet`, after getting it with
`useCapability`. Other modules read them the same way they read any capability.
## Limits
- Settings are not ECHO objects, so they do not appear in a space or in search.
- Change a settings schema only by adding optional fields, so values stored by an earlier version still decode.
---
# App Graph
The graph of nodes and actions that an app's navigation and menus are built from, and how a plugin adds to it.
```ts
import * as AppGraph from '@dxos/app-graph/AppGraph';
import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder';
import * as AppGraphNode from '@dxos/app-graph/AppGraphNode';
import * as AppNode from '@dxos/app-toolkit/AppNode';
```
App Graph is a graph of everything a person can open or do in a PLEX app. In Composer, the navigation tree,
toolbars, context menus and the command palette all read it, so a plugin that adds to App Graph shows up in all of
them.
App Graph comes from the App Toolkit, not the framework: it is how a workspace app such as Composer organizes what
plugins add. A PLEX app can run without it.
## How App Graph is shaped
A **node** has an id, a type, data and properties. Its data is usually the thing it stands for, such as a space or an
ECHO object; its properties carry the label, icon and display hints. The graph starts from a root node.
Nodes connect by **relations**. `child` makes the navigation tree: a space's children are its sections and objects.
`action` connects a node to the things a person can do to it; an action node's data is a function that returns an
Effect. Other relations, such as `companion`, attach panels to a node.
A node's identity is its **path**: the ids from the root down to it, joined like a URL. `GraphPath` from
`@dxos/app-toolkit` builds the paths of the common nodes, such as a space or an object in it, and navigation
operations take paths. In Composer the address bar shows the open node's path.
Plugins add nodes through **builder extensions**. An extension has a matcher and a connector: the matcher picks the
nodes it applies to, and the connector returns the nodes to connect to them. For the usual section that lists an
object type, `TypeSection.createTypeSectionExtension` builds the extensions for you, with an address for each object.
Stash uses it:
```ts
import * as AppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher';
import * as GraphPath from '@dxos/app-toolkit/GraphPath';
import * as TypeSection from '@dxos/app-toolkit/TypeSection';
import { StashedLink } from '../types/StashedLink.ts';
/** A "Stashed links" section in each space's Content group, once it has one. */
const section = TypeSection.createTypeSectionExtension(StashedLink, {
match: AppNodeMatcher.whenNavTreeGroup(GraphPath.GroupTypes.content),
groupSegment: GraphPath.GroupSegments.content,
// The link's address in Composer's URL: `/w//stash/`.
urlKey: 'stash',
});
```
Stash's App Graph module in the inspector shows the branches that lead to what it added:
## How it behaves
- App Graph expands lazily. The framework runs a node's extensions only when something asks for that node's
connections, such as the person opening a branch in the tree. A space with ten thousand objects costs nothing until
it is opened.
- A connector reads reactive state through `get`. When that state changes, such as a query gaining a result, the
connector reruns and the node's connections update.
- Nodes nothing is showing can be dropped again. A plugin that must keep a node loaded while it is off screen, such as
the one a panel is pinned to, contributes it through `AppCapabilities.AppGraphRetention`.
- Actions reach every menu from one place. An action node carries a disposition that says where it appears: `toolbar`
for an object's toolbar, `list-item` for its context menu in the tree. The command palette lists every action of the
open node, so an action needs no command of its own.
```ts
import * as Effect from 'effect/Effect';
import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder';
import * as Operation from '@dxos/compute/Operation';
import { Obj, Ref } from '@dxos/echo';
import { StashNamespace } from '../namespace.ts';
import { StashedLink } from '../types/StashedLink.ts';
import { MarkRead } from '../types/StashedLinkOperation.ts';
/** An action on every link, on its toolbar and its menu in the tree. */
const stashedLinkActions = AppGraphBuilder.createTypeExtension({
id: 'stashedLinkActions',
type: StashedLink,
actions: (link) =>
Effect.succeed([
{
id: 'markRead',
data: () =>
Operation.invoke(
MarkRead,
{ link: Ref.make(link) },
{ spaceId: Obj.getDatabase(link)?.spaceId },
),
properties: {
label: ['mark-read.label', { ns: StashNamespace.key }],
icon: 'ph--check-circle--regular',
disposition: ['toolbar', 'list-item'],
},
},
]),
});
```
## Write a graph builder extension
`AppGraphBuilder.createExtension` takes an id, a matcher and a connector. The matcher returns `Option.some(value)` for
the nodes it applies to; `AppNodeMatcher` has ready-made ones such as `whenSpace`. The connector returns the nodes to
connect. Read reactive state with `get` so the connector reruns when it changes.
```ts
import * as Effect from 'effect/Effect';
import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder';
import * as AppNode from '@dxos/app-toolkit/AppNode';
import * as AppNodeMatcher from '@dxos/app-toolkit/AppNodeMatcher';
import { Filter } from '@dxos/echo';
// One node per note, as a child of each space node.
const notes = AppGraphBuilder.createExtension({
id: 'notes',
match: AppNodeMatcher.whenSpace,
connector: (space, get) => {
const objects = get(space.db.query(Filter.type(Note)).atom);
return Effect.succeed(
objects
.map((object) => AppNode.makeObject({ get, db: space.db, object }))
.filter((node): node is NonNullable => node !== null),
);
},
});
```
## Attach actions to nodes
`AppGraphBuilder.createTypeExtension` matches nodes whose data is an object of one type. Its `actions` callback returns
action nodes. `AppGraphNode.makeAction` makes one; `disposition` decides where it shows. `AppNode.makeToolbarAction`
is a shortcut for the object toolbar.
```ts
import * as Effect from 'effect/Effect';
import * as AppGraphBuilder from '@dxos/app-graph/AppGraphBuilder';
import * as AppGraphNode from '@dxos/app-graph/AppGraphNode';
import { Obj } from '@dxos/echo';
// An action on every node whose data is a Note.
const pin = AppGraphBuilder.createTypeExtension({
id: 'pin',
type: Note,
actions: (note) =>
Effect.succeed([
AppGraphNode.makeAction({
id: 'pin',
data: () =>
Effect.sync(() =>
Obj.update(note, (note) => (note.pinned = !note.pinned)),
),
properties: {
label: 'Pin',
icon: 'ph--push-pin--regular',
disposition: 'list-item',
},
}),
]),
});
```
Both builders return Effects. Run them in the module body and contribute the result through
`AppCapabilities.AppGraphBuilder`.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
import * as AppCapabilities from '@dxos/app-toolkit/AppCapabilities';
export default Capability.makeModule(
Effect.fnUntraced(function* () {
const extensions = yield* Effect.all([notes, pin]);
return Capability.contribute(
AppCapabilities.AppGraphBuilder,
extensions.flat(),
);
}),
);
```
## Work with AppNode and GraphPath
`AppNode` builds the common kinds of node: `makeObject` for an ECHO object, `makeSection` for a heading in the tree,
`makeCompanion` and `makeDeckCompanion` for panels. `GraphPath` builds and parses node paths, which navigation
operations take.
```ts
import * as GraphPath from '@dxos/app-toolkit/GraphPath';
export const pathOfNote = (note: Note, spaceId: string) =>
GraphPath.getSpacePath(spaceId, note.id);
```
## Expand and read the graph from a component
`useAppGraph` returns the graph. Expand a node before reading its connections, because the graph builds them lazily.
`graph.connections(id, relation)` is the same data as an atom, for a component that should update as the graph changes.
```ts
import { useEffect, useState } from 'react';
import * as AppGraph from '@dxos/app-graph/AppGraph';
import * as AppGraphNode from '@dxos/app-graph/AppGraphNode';
import * as Hooks from '@dxos/app-toolkit/Hooks';
import * as EffectEx from '@dxos/effect/EffectEx';
export const ChildCount = ({ id }: { id: string }) => {
const { graph } = Hooks.useAppGraph();
const [count, setCount] = useState(0);
useEffect(() => {
// Children are built lazily: expand the node, then read its connections.
void EffectEx.runPromise(
AppGraph.expand(graph, id, AppGraphNode.child),
).then(() =>
setCount(AppGraph.getConnections(graph, id, AppGraphNode.child).length),
);
}, [graph, id]);
return {count};
};
```
## Limits
- An extension's id and a node's id must be camelCase. A hyphenated id is dropped without an error.
- A connector must be cheap and must not write. It runs again on every change to the state it reads.
- Paths are stable only while the ids along them are. Moving an object to another space changes its path.
## Related
| Page | What it covers |
| --- | --- |
| [Extend the App Graph](/docs/plex/learn/extend-app-graph/) | Sections, items and actions end to end. |
| [Operation](/docs/plex/guides/operation/) | The operations that actions invoke. |
---
# React
The React hooks for plugins.
```ts
import * as Hooks from '@dxos/app-framework/Hooks';
```
A plugin's components read capabilities, invoke operations and read settings through hooks. They work in any component
under the app's plugin manager, which every surface is.
## Read capabilities from a component
`useCapability` returns a singleton capability, and `useCapabilities` every contribution to a multi capability. Both
re-render when contributions change. `useOptionalCapability` returns `undefined` when no plugin provides it.
```ts
import * as Capabilities from '@dxos/app-framework/Capabilities';
import * as Hooks from '@dxos/app-framework/Hooks';
export const SurfaceCount = () => {
// Every contribution to a multi capability, re-rendering when plugins add or
// remove one.
const surfaces = Hooks.useCapabilities(Capabilities.ReactSurface);
return {surfaces.length};
};
```
## Invoke operations from a component
`useOperation` binds an operation to a callback; its argument maps the callback's arguments to the operation's input.
`useOperationInvoker` returns the invoker, whose `invokePromise` returns the operation's output.
```ts
import { useCallback } from 'react';
import * as Hooks from '@dxos/app-framework/Hooks';
import * as LayoutOperation from '@dxos/app-toolkit/LayoutOperation';
import * as Button from '@dxos/react-ui/Button';
export const OpenButton = ({ path }: { path: string }) => {
const open = Hooks.useOperation(LayoutOperation.Open, () => ({
subject: [path],
}));
return open()}>Open ;
};
export const OpenTwice = ({ path }: { path: string }) => {
const { invokePromise } = Hooks.useOperationInvoker();
const handleClick = useCallback(async () => {
await invokePromise(LayoutOperation.Open, { subject: [path] });
}, [invokePromise, path]);
return Open ;
};
```
## Read plugin settings and translations
`useAtomCapability` reads an atom capability, such as a plugin's settings, and re-renders when it changes.
`useAtomCapabilityState` also returns a setter. `useTranslation` from `@dxos/react-ui/Hooks` takes the plugin's
namespace.
```ts
import * as Hooks from '@dxos/app-framework/Hooks';
import * as Button from '@dxos/react-ui/Button';
import * as UiHooks from '@dxos/react-ui/Hooks';
export const CompactToggle = () => {
const [settings, setSettings] = Hooks.useAtomCapabilityState(Settings);
const { t } = UiHooks.useTranslation('com.example.plugin.notes');
return (
setSettings((current) => ({ ...current, compact: !current.compact }))
}
>
{settings.compact ? t('expand.label') : t('compact.label')}
);
};
```
`@dxos/app-toolkit/Hooks` adds hooks for app state, such as `useActiveSpace`, `useAppGraph` and `useLayout`.
## The reactivity rules
- Reading an ECHO object's field in render does not subscribe the component. Use `useObject` for a snapshot that does,
and `useQuery` for a list. See the ECHO [React guide](/docs/echo/guides/react/).
- Write objects inside `Obj.update`, from an event handler or an operation, never during render.
- A component never assumes it is the only view of an object. The same object can be open in another plank, on another
device, or in the assistant's hands.
```ts
import { useCallback } from 'react';
import * as ToolkitHooks from '@dxos/app-toolkit/Hooks';
import { Obj } from '@dxos/echo';
import { useObject } from '@dxos/echo-react';
import * as Button from '@dxos/react-ui/Button';
export const NoteTitle = ({ note }: { note: Note }) => {
// Reading `note.title` in render does not subscribe. `useObject` returns a
// snapshot that does.
const [snapshot] = useObject(note);
const space = ToolkitHooks.useActiveSpace();
const rename = useCallback(
() => Obj.update(note, (note) => (note.title = 'Renamed')),
[note],
);
return (
{snapshot.title}
);
};
```
## Related
| Page | What it covers |
| --- | --- |
| [Surface](/docs/plex/guides/surface/) | Where these components render. |
| [Operation](/docs/plex/guides/operation/) | Defining the operations a component invokes. |
---
# ECHO
Register a plugin's ECHO types with the app, so it can create, name and list their objects.
A plugin stores its data as ECHO objects in the person's spaces. The types themselves are ordinary ECHO types,
which [Defining types](/docs/echo/learn/defining-types/) covers. This page covers what a plugin adds so the app knows
about them.
## Register the types
The app can store, query and show an object only if its type is registered. A plugin registers its types with the
`Schema` capability: a module returns the list, and `AppCapability.schema` contributes it.
```ts
import { StashedLink } from '../types/StashedLink.ts';
export default [StashedLink];
```
```ts
AppCapability.schema(
() =>
import('./src/capabilities/schema.ts', {
with: { type: 'module-url' },
}),
),
```
A type registered by no plugin still syncs, since ECHO stores any object, but the app cannot name, list or create it.
## Annotate the type for the app
Annotations on the schema tell the app how to present an object:
- `LabelAnnotation` names the property that holds the object's label, shown in the navigation tree and in search.
- `IconAnnotation` gives the type an icon and a hue.
- `UserType` marks a type people create themselves, so it appears in the app's create menus. Leave it off types
that only a plugin creates, such as a sync cursor.
```ts
import * as Schema from 'effect/Schema';
import { Annotation, DXN, Type } from '@dxos/echo';
export class StashedLink extends Type.makeObject(
DXN.make('com.example.type.stashedLink', '0.1.0'),
)(
Schema.Struct({
title: Schema.optional(Schema.String.annotate({ title: 'Title' })),
url: Schema.optional(Schema.String.annotate({ title: 'URL' })),
note: Schema.optional(Schema.String.annotate({ title: 'Note' })),
read: Schema.optional(Schema.Boolean.annotate({ title: 'Read' })),
}).pipe(
Annotation.LabelAnnotation.set(['title']),
Annotation.IconAnnotation.set({
icon: 'ph--tray-arrow-down--regular',
hue: 'indigo',
}),
Annotation.UserType.set(),
),
) {}
```
The type's name in the menus comes from the plugin's translations, keyed by its typename. See
[App capabilities](/docs/plex/guides/app-capabilities/#translations).
## Create objects
Give each type a `make` function that fills in defaults, so every plugin that creates one gets a valid object.
```ts
import { Obj } from '@dxos/echo';
export const make = (
props: Partial> = {},
): StashedLink =>
Obj.make(StashedLink, {
title: props.title,
url: props.url,
note: props.note ?? '',
read: props.read ?? false,
});
```
To add the type to the app's create menu, contribute a `SpaceCapabilities.CreateObjectEntry`. The Space plugin lists
every entry, and calls `createObject` with the properties the person entered and the place they chose.
`SpaceOperation.AddObject` adds the object to the space, so the change can be undone and other plugins see it.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
import * as Operation from '@dxos/compute/Operation';
import { Type } from '@dxos/echo';
import * as SpaceCapabilities from '@dxos/plugin-space/SpaceCapabilities';
import * as SpaceOperation from '@dxos/plugin-space/SpaceOperation';
import { StashedLink, make } from '../types/StashedLink.ts';
export default Capability.makeModule(() =>
Effect.succeed(
Capability.contribute(SpaceCapabilities.CreateObjectEntry, {
id: Type.getTypename(StashedLink),
createObject: (props, options) =>
Operation.invoke(
SpaceOperation.AddObject,
{ object: make(props), target: options.target },
{ spaceId: options.db.spaceId },
),
}),
),
);
```
## Limits
- A plugin can register only types it defines or depends on. It cannot change another plugin's type.
- Turning a plugin off does not delete its objects. They stay in the space, and the app shows them again once the
plugin is back on.
---
# Plugin
Define a plugin and its modules.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
```
`Plugin` defines a plugin: its metadata and the modules it is made of. A plugin does nothing by itself; its modules
contribute everything it adds to the app.
## Define a plugin with its metadata
A plugin's metadata names it. The key is a reverse-domain DXN whose last segment is camelCase.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
import { DXN } from '@dxos/echo';
const meta = Plugin.makeMeta({
key: DXN.make('com.example.plugin.hello'),
name: 'Hello',
description: 'Contributes one greeting.',
});
```
Plugins in a package with a `dx.config.ts` describe the same metadata there, and `Plugin.config` derives the `meta`.
[Create a plugin](/docs/plex/learn/create-plugin/) shows that form.
## Add modules and choose their activation events
A module declares the capabilities it `provides` and `requires`, and optionally the event it `activatesOn`. The plugin
manager activates a module when its event fires and every capability it requires has been contributed. A module with no
`activatesOn` activates when the app is idle after startup.
```ts
import * as Effect from 'effect/Effect';
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
import * as Capability from '@dxos/app-framework/Capability';
const Greeting = Capability.make()(
'com.example.plugin.hello.capability.greeting',
);
const GreetingModule = Capability.inlineModule(
'Greeting',
{ provides: [Greeting], activatesOn: ActivationEvents.Startup },
() => Effect.succeed(Capability.contribute(Greeting, 'Hello, world')),
);
```
`Capability.inlineModule` keeps the body in the same file. `Capability.lazyModule` loads it from another chunk when the
module activates, so a plugin with many modules costs little at startup.
```ts
import * as Effect from 'effect/Effect';
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
import * as Capability from '@dxos/app-framework/Capability';
// The module body loads only when its event fires. The loader's default export
// is checked against `provides`.
const LazyGreetingModule = Capability.lazyModule(
'LazyGreeting',
{ provides: [Greeting], activatesOn: ActivationEvents.Idle },
async () => ({
default: Capability.makeModule(() =>
Effect.succeed(Capability.contribute(Greeting, 'Hello, later')),
),
}),
);
```
`Plugin.define` starts a plugin from its metadata, `Plugin.addModule` adds each module, and `Plugin.make` returns the
factory the plugin manager calls.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
export const HelloPlugin = Plugin.define(meta).pipe(
Plugin.addModule(GreetingModule),
Plugin.make,
);
```
## Contribute capabilities from a module
A module body returns the contributions it declared in `provides`. The compiler checks the match: a missing or
undeclared contribution is a type error at `Plugin.addModule`. [Capability](/docs/plex/guides/capability/) covers
declaring and requesting capabilities.
## Describe the plugin in `dx.config.ts`
A plugin that is published describes itself in a `dx.config.ts` manifest. `Plugin.config` takes the metadata, the
registry's fields such as tags, and the modules, each made with a maker from `AppCapability` that names the file to
load. It returns `meta` and `make`, so the registry reads the plugin without importing its modules.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
import * as AppCapability from '@dxos/app-toolkit/AppCapability';
import { StashNamespace } from './src/namespace.ts';
const config: Plugin.PluginConfig = Plugin.config({
plugin: {
key: StashNamespace.key,
name: 'Stash',
author: 'Example',
description:
'A reading list: save links with a note, and mark them as read.',
icon: { key: 'ph--tray-arrow-down--regular', hue: 'indigo' },
tags: ['labs'],
},
modules: [
// #region translations
AppCapability.translations(
() => import('./src/translations.ts', { with: { type: 'module-url' } }),
),
```
[Testing a plugin](/docs/plex/guides/testing/)
boots a plugin in a headless app.
## Related
| Page | What it covers |
| --- | --- |
| [Plugins and modules](/docs/plex/concepts/plugins/) | How plugins, modules, events and capabilities fit together. |
| [ActivationEvent](/docs/plex/guides/activation-event/) | Declaring events and the built-in ones. |
| [API reference](/docs/plex/resources/api-reference/) | Every export of `@dxos/app-framework`. |
---
# Capability
Declare, contribute and request capabilities, including the built-in ones.
```ts
import * as Capabilities from '@dxos/app-framework/Capabilities';
import * as Capability from '@dxos/app-framework/Capability';
```
A capability is a typed slot that modules contribute values to and other modules read. Capabilities are how plugins
extend each other without importing each other.
## Declare a new capability type
`Capability.make` declares a multi capability, which any number of modules contribute to. `Capability.makeSingleton`
declares one with exactly one provider. Both take the value's type and an NSID.
```ts
import * as Capability from '@dxos/app-framework/Capability';
export type Exporter = {
readonly format: string;
readonly export: (text: string) => Uint8Array;
};
// A multi capability: any number of modules may contribute one.
export const Exporter = Capability.make()(
'com.example.plugin.export.capability.exporter',
);
// A singleton capability: exactly one module provides it.
export const Clock = Capability.makeSingleton<() => Date>()(
'com.example.plugin.export.capability.clock',
);
```
`Plugin.namespace` derives the NSID from the plugin's key, so a plugin's capabilities share its prefix.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
const ExportNamespace = Plugin.namespace('com.example.plugin.export');
export const Settings = ExportNamespace.makeSingleton<{
defaultFormat: string;
}>()('settings');
```
## Contribute an implementation and request all implementations
A module lists the capability in `provides` and returns `Capability.contribute(tag, value)`.
```ts
import * as Effect from 'effect/Effect';
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
import * as Capability from '@dxos/app-framework/Capability';
export const TextExporterModule = Capability.inlineModule(
'TextExporter',
{ provides: [Exporter], activatesOn: ActivationEvents.Startup },
() =>
Effect.succeed(
Capability.contribute(Exporter, {
format: 'text/plain',
export: (text) => new TextEncoder().encode(text),
}),
),
);
```
A module that lists a capability in `requires` activates only after it is contributed, and reads it with `yield*`.
`Capability.getAll` reads every contribution to a multi capability.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
export const ExportMenuModule = Capability.inlineModule(
'ExportMenu',
{ requires: [Clock], provides: [] },
() =>
Effect.gen(function* () {
const now = yield* Clock;
const exporters = yield* Capability.getAll(Exporter);
console.log(
now(),
exporters.map((exporter) => exporter.format),
);
}),
);
```
## The built-in capabilities
`Capabilities` holds the capabilities the framework itself defines. The ones most plugins touch:
| Capability | What a plugin does with it |
| --- | --- |
| `ReactSurface` | Contributes surface components. See [Surface](/docs/plex/guides/surface/). |
| `OperationHandler` | Contributes operation handlers. See [Operation](/docs/plex/guides/operation/). |
| `OperationInvoker` | Reads it to invoke operations outside a handler. |
| `ReactContext`, `ReactRoot` | Wraps the app in a provider, or adds a root-level component. |
| `AtomRegistry` | Reads and writes atoms outside React. |
The App Toolkit's capabilities, such as settings and App Graph, are in [App capabilities](/docs/plex/guides/app-capabilities/).
```ts
import * as Effect from 'effect/Effect';
import * as Capabilities from '@dxos/app-framework/Capabilities';
import * as Capability from '@dxos/app-framework/Capability';
// Built-in capabilities are tags like any other: a singleton yields its one
// implementation.
export const ReportModule = Capability.inlineModule(
'Report',
{ requires: [Capabilities.OperationInvoker], provides: [] },
() =>
Effect.gen(function* () {
const invoker = yield* Capabilities.OperationInvoker;
console.log(typeof invoker.invokePromise);
}),
);
```
## React to capabilities that arrive after startup
Contributions change as people turn plugins on and off. `Capability.contributions` returns a live view with `get` and
`subscribe`; `Capability.atom` returns the same as an atom for reactive code.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
// Contributions that arrive after startup, for example from a plugin the user
// turns on later.
// The view is live: a module subscribes, and the finalizer unsubscribes when
// the module deactivates.
export const ExportListModule = Capability.inlineModule(
'ExportList',
{ provides: [] },
() =>
Effect.gen(function* () {
const exporters = yield* Capability.contributions(Exporter);
const unsubscribe = exporters.subscribe((current) =>
console.log(current.map((exporter) => exporter.format)),
);
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
}),
);
```
In React, `useCapabilities` re-renders on each change. See [React](/docs/plex/guides/react/).
## Related
| Page | What it covers |
| --- | --- |
| [Plugin](/docs/plex/guides/plugin/) | Modules and how they declare `provides` and `requires`. |
| [App capabilities](/docs/plex/guides/app-capabilities/) | The app-level capabilities the App Toolkit defines. |
---
# ActivationEvent
Declare activation events and use the built-in ones.
```ts
import * as ActivationEvent from '@dxos/app-framework/ActivationEvent';
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
```
An activation event tells the plugin manager when a module should load. Modules wait for their event, so a plugin's code loads
when it is needed, not all at startup.
## Declare an activation event
`ActivationEvent.make` declares an event from an NSID. `ActivationEvent.pluginStart` returns a plugin's own start event,
which the plugin manager fires on demand and while it is idle.
```ts
import * as ActivationEvent from '@dxos/app-framework/ActivationEvent';
// The last segment of the NSID must be camelCase; the compiler rejects
// `index-ready`.
export const IndexRequested = ActivationEvent.make(
'com.example.plugin.search.event.indexRequested',
);
// Every plugin has a start event of its own, fired on demand and by the host's
// idle trickle.
export const SearchStart = ActivationEvent.pluginStart(
'com.example.plugin.search',
);
```
## Order modules with events and requirements
A module's `activatesOn` can name one event, or combine several with `ActivationEvent.oneOf` and
`ActivationEvent.allOf`.
```ts
import * as Effect from 'effect/Effect';
import * as ActivationEvent from '@dxos/app-framework/ActivationEvent';
import * as Capability from '@dxos/app-framework/Capability';
export const IndexModule = Capability.inlineModule(
'Index',
{
provides: [Index],
activatesOn: ActivationEvent.oneOf(IndexRequested, SearchStart),
},
() => Effect.succeed(Capability.contribute(Index, new Map())),
);
```
Requirements order modules within an event. A module that requires a capability activates after the module that
provides it, whichever event activated the provider. Registration order does not matter.
```ts
import * as Effect from 'effect/Effect';
import * as Capability from '@dxos/app-framework/Capability';
// No `activatesOn`: this module waits for `Index`, so it activates whenever the
// module above does.
export const QueryModule = Capability.inlineModule(
'Query',
{ requires: [Index], provides: [] },
() =>
Effect.gen(function* () {
const index = yield* Index;
console.log(index.size);
}),
);
```
A module fires an event with `Plugin.activate`. This is how a plugin loads another plugin's modules on demand, for
example when a person opens the create dialog.
```ts
import * as Plugin from '@dxos/app-framework/Plugin';
export const requestIndex = Plugin.activate(IndexRequested);
```
## The built-in events
| Event | When it fires |
| --- | --- |
| `ActivationEvents.Startup` | Once, while the app boots. Only modules needed for the first paint belong here. |
| `ActivationEvents.Idle` | After the first paint, when the app is idle. The default for a module with no `activatesOn`. |
| `ActivationEvents.SurfacesRequested(role)` | The first time a surface of that role is rendered. |
| `ActivationEvents.CommandsRequested` | When the command palette or CLI asks for commands. |
| `ActivationEvents.PluginAssetsRequested` | When a tool asks for plugin assets, such as a spec. |
```ts
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
export const events = [
ActivationEvents.Startup,
ActivationEvents.Idle,
ActivationEvents.SurfacesRequested('org.dxos.role.article'),
];
```
Module makers such as `AppCapability.surface` set the right event for their capability, so most manifests never name
one.
## Related
| Page | What it covers |
| --- | --- |
| [Plugin](/docs/plex/guides/plugin/) | Defining modules. |
| [Capability](/docs/plex/guides/capability/) | Requirements between modules. |
---
# PluginManager
Create the plugin manager, and add, enable and remove plugins.
```ts
import * as PluginManager from '@dxos/app-framework/PluginManager';
import * as UrlLoader from '@dxos/app-framework/UrlLoader';
```
The plugin manager holds an app's plugins, decides which are enabled, and activates their modules as events fire. An
app such as Composer creates one at boot; a plugin reaches it through `Capabilities.PluginManager` when it needs to
turn other plugins on or off. [Activation](/docs/plex/concepts/activation/) explains the order it activates modules in.
## Create a plugin manager
`PluginManager.make` takes the plugins the app knows about and the ones enabled at start. Plugins tagged `system`
are always enabled. The `pluginLoader` turns an id the manager is later asked to add into a plugin.
```ts
import * as PluginManager from '@dxos/app-framework/PluginManager';
import * as UrlLoader from '@dxos/app-framework/UrlLoader';
import { make } from './dx.config.ts';
const stash = make();
export const manager = PluginManager.make({
plugins: [stash],
enabled: [stash.meta.profile.key],
// Turns an id `add` is given into a plugin; this one loads manifest URLs.
pluginLoader: UrlLoader.make([]),
});
```
`start` runs the startup pass: it activates the Startup modules and every module whose requirements they satisfy.
An app fires `Idle` once it has painted. A React app renders `App.Root` from `@dxos/app-framework/App`, which does both.
```ts
import * as Effect from 'effect/Effect';
import * as ActivationEvents from '@dxos/app-framework/ActivationEvents';
export const start = Effect.gen(function* () {
yield* manager.start();
yield* manager.activate(ActivationEvents.Idle);
});
```
## Enable, disable and remove plugins
`enable` turns on a plugin and the plugins it declares in `dependsOn`, then activates every module whose event has
fired. `disable` deactivates a plugin's modules and, by default, the plugins that depend on it. `remove` disables a
plugin and forgets it.
```ts
import * as Effect from 'effect/Effect';
export const toggle = (key: string) =>
Effect.gen(function* () {
if (manager.getEnabled().includes(key)) {
// Also disables the plugins that depend on it.
yield* manager.disable(key);
} else {
yield* manager.enable(key);
}
});
```
## Load a plugin from a URL
`add` hands an id to the plugin loader and registers the result without enabling it. `UrlLoader.make` loads a plugin
from its manifest URL, which is how Composer's Load dev plugin and the docs' [inspector](/docs/plex/guides/debugging/)
load a plugin from its dev server. The page needs an import map of the shared packages, so the plugin uses the page's
copies.
```ts
import * as Effect from 'effect/Effect';
export const loadDevPlugin = Effect.gen(function* () {
// The plugin's own key may differ from the URL it was loaded from.
const plugin = yield* manager.add('http://localhost:3967/manifest.json');
yield* manager.enable(plugin.meta.profile.key);
});
```
## Read which plugins and modules are active
Each piece of state has a getter for a one-off read and an atom for a component that should update. `getFailed`
lists plugins that failed to load or activate, with the phase and the error.
```ts
export const report = () => ({
enabled: manager.getEnabled(),
activeModules: manager.getActive(),
failed: manager
.getFailed()
.map(({ id, phase, reason }) => ({ id, phase, reason })),
});
// The same state as atoms, for a component that should update.
export const enabledAtom = manager.enabled;
```
## Members at a glance
| Member | What it does |
| --- | --- |
| `make(options)` | Creates a manager. |
| `start()`, `activate(event)` | Runs startup; fires an event. |
| `add(id)`, `enable(id)`, `disable(id)`, `remove(id)` | Changes which plugins are loaded and on. |
| `plugins`, `enabled`, `active`, `failed` | Atoms of the manager's state; each has a `get…` reader. |
| `getDependencies(id)`, `getDependents(id)` | The plugins a plugin needs, or that need it. |
| `capabilities` | The capability manager that holds every contribution. |
## Related
| Page | What it covers |
| --- | --- |
| [Activation](/docs/plex/concepts/activation/) | When the manager activates each module. |
| [Plugin](/docs/plex/guides/plugin/) | Defining the plugins the manager holds. |
| [Testing a plugin](/docs/plex/guides/testing/) | A manager in a headless test app. |
---
# Operation
Define operations, implement their handlers and invoke them.
```ts
import * as Operation from '@dxos/compute/Operation';
import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
```
An operation is a named action with a typed input and output. [Operations](/docs/plex/concepts/operations/)
explains why the UI and agents share them.
## Define an operation with input and output schema
`Operation.make` takes a key, metadata and two schemas. Annotations such as `Operation.mutation` describe the
operation's effect, which tools show before running it.
```ts
import * as Schema from 'effect/Schema';
import * as Operation from '@dxos/compute/Operation';
import { DXN } from '@dxos/echo';
export const Slugify = Operation.make({
meta: {
key: DXN.make('com.example.operation.text.slugify'),
name: 'Slugify',
description: 'Turn a title into a URL slug.',
},
input: Schema.Struct({ title: Schema.String }),
output: Schema.Struct({ slug: Schema.String }),
}).pipe(Operation.mutation('none'));
```
## Implement handlers and group them
`Operation.withHandler` attaches an Effect to a definition.
```ts
import * as Effect from 'effect/Effect';
import * as Operation from '@dxos/compute/Operation';
export const SlugifyHandler = Slugify.pipe(
Operation.withHandler(({ title }) =>
Effect.succeed({
slug: title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-'),
}),
),
);
```
`OperationHandlerSet.make` groups handlers that are already loaded. `OperationHandlerSet.lazy` takes lazy handlers,
whose modules load the first time each operation runs. Contribute the set through `Capabilities.OperationHandler`.
```ts
import * as Operation from '@dxos/compute/Operation';
import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
export const TextHandlers = OperationHandlerSet.make(SlugifyHandler);
// Lazy: the handler's module loads the first time the operation runs.
export const LazyTextHandlers = OperationHandlerSet.lazy([
Slugify.pipe(
Operation.lazyHandler(async () => ({ default: SlugifyHandler })),
),
]);
```
## Invoke an operation and handle its typed errors
`Operation.invoke` returns an Effect that needs the operation service, which the framework provides to modules, handlers and
graph actions. It fails with `NoHandlerError` when no plugin handles the operation.
```ts
import * as Effect from 'effect/Effect';
import * as Operation from '@dxos/compute/Operation';
export const slugOf = (title: string) =>
Operation.invoke(Slugify, { title }).pipe(
Effect.map(({ slug }) => slug),
Effect.catchTag('NoHandlerError', () => Effect.succeed(title)),
);
```
From React, use `useOperation` or `useOperationInvoker`. See [React](/docs/plex/guides/react/).
## The built-in layout operations
`LayoutOperation` from `@dxos/app-toolkit` changes what is on screen. `Open` and `Close` take node paths; `Set`
replaces the open items; `ScrollIntoView`, `UpdateSidebar`, `UpdateDialog` and `AddToast` do what their names say.
```ts
import * as LayoutOperation from '@dxos/app-toolkit/LayoutOperation';
import * as Operation from '@dxos/compute/Operation';
export const openObject = (path: string) =>
Operation.invoke(LayoutOperation.Open, { subject: [path] });
```
## Related
| Page | What it covers |
| --- | --- |
| [Add an operation](/docs/plex/learn/add-operation/) | An operation end to end, including the assistant. |
| [App Graph](/docs/plex/guides/app-graph/) | Attaching an operation to nodes as an action. |
---
# Surface
Contribute surfaces and render other plugins' surfaces.
```ts
import * as Surface from '@dxos/app-framework/Surface';
import * as AppSurface from '@dxos/app-toolkit/AppSurface';
```
`Surface` creates surface contributions and renders surfaces. `AppSurface` holds the App Toolkit's roles and the filters that
match them. [Surfaces](/docs/plex/concepts/surfaces/) explains how matching works.
## Contribute a surface for an object type
`Surface.create` takes an id, a filter, a component and a `props` mapper. `AppSurface.object(role, Type)` matches a role
when the data's subject is an object of the type.
```ts
import * as Surface from '@dxos/app-framework/Surface';
import * as AppSurface from '@dxos/app-toolkit/AppSurface';
export const NoteArticleSurface = Surface.create({
id: 'article',
// Matches the article role when the data's subject is a Note.
filter: AppSurface.object(AppSurface.Article, Note),
component: NoteArticle,
// Maps the surface's data onto the component's props; checked against the
// filter's data type.
props: ({ role, data: { subject, attendableId } }) => ({
role,
subject,
attendableId,
}),
});
```
Contribute surfaces from a module through `Capabilities.ReactSurface`. In a manifest, `AppCapability.surface` makes
that module and loads it when one of its `roles` is first requested.
```ts
import * as Effect from 'effect/Effect';
import * as Capabilities from '@dxos/app-framework/Capabilities';
import * as Capability from '@dxos/app-framework/Capability';
export default Capability.makeModule(() =>
Effect.succeed(
Capability.contribute(Capabilities.ReactSurface, [
NoteArticleSurface,
NoteCardSurface,
]),
),
);
```
## Match on data and set the order
`AppSurface.oneOf` matches any of several filters, and `AppSurface.allOf` requires all of them. Other filters match a
literal value (`AppSurface.literal`), the object a companion panel is attached to (`AppSurface.companion`), or any
subject (`AppSurface.subject`).
```ts
import * as Surface from '@dxos/app-framework/Surface';
import * as AppSurface from '@dxos/app-toolkit/AppSurface';
export const NoteCardSurface = Surface.create({
id: 'card',
filter: AppSurface.oneOf(
AppSurface.object(AppSurface.CardContent, Note),
AppSurface.object(AppSurface.Section, Note),
),
component: NoteCard,
props: ({ data: { subject } }) => ({ subject }),
});
```
When several surfaces match, `position` on the contribution orders them. Pass `Position.first` or `Position.last` from
`@dxos/util` to come before or after the rest.
## Render a nested surface from a component
`Surface.Surface` renders whatever surface matches a role and data. Pass `limit` to cap how many render.
```ts
import * as Surface from '@dxos/app-framework/Surface';
import * as AppSurface from '@dxos/app-toolkit/AppSurface';
// Renders whatever surface another plugin contributed for this object in the
// section role.
export const Embedded = ({ subject }: { subject: Note }) => (
);
```
## The standard roles
| Role | Where it renders | Data |
| --- | --- | --- |
| `Article` | The main content area, one object per plank. | `{ subject, attendableId }` |
| `Section` | An object inside a stack or another document. | `{ subject, attendableId }` |
| `CardContent`, `CardIcon`, `CardMenu` | Parts of an object's card. | `{ subject }` |
| `ObjectProperties` | The properties panel of an object. | `{ subject }` |
| `Dialog`, `Popover` | A dialog or popover opened by an operation. | Set by the caller. |
| `StatusIndicator` | The status bar. | None. |
| `deckCompanion(variant)` | A workspace-wide panel beside the deck. | `{ id?, subject? }` |
## Related
| Page | What it covers |
| --- | --- |
| [Add a surface](/docs/plex/learn/add-surface/) | A surface end to end. |
| [React](/docs/plex/guides/react/) | Hooks a surface component uses. |
---
# API reference
Generated API reference for the PLEX packages.
- `@dxos/app-framework`: plugins, modules,
capabilities, activation events and the plugin manager.
- `@dxos/app-toolkit`: the capabilities, makers and
surfaces a workspace app adds.
- `@dxos/app-graph`: App Graph, its nodes and its
builder.
- `@dxos/compute`: operations and layer specs.
---
# Glossary
Every Composer and PLEX term, defined once.
ECHO terms, such as space and object, are in ECHO's [glossary](/docs/additional-resources/glossary/). Identity and
invitation terms are in HALO's [glossary](/docs/halo/resources/glossary/).
### Action
A [node](#node) in [App Graph](#app-graph) that stands for something a person can do to another node. Its data is a
function that returns an Effect. The action's disposition says where it appears, such as an object's toolbar or its
context menu. See [App Graph](/docs/plex/guides/app-graph/).
### Activation event
An event that tells the [plugin manager](#plugin-manager) when to load a [module](#module), such as startup or the
first time a view is needed. See [ActivationEvent](/docs/plex/guides/activation-event/).
### Annotation
Metadata on an ECHO schema that tells the app how to present its objects, such as which property is the label, the
type's icon, or whether people create it themselves. See [Using ECHO in a plugin](/docs/plex/guides/using-echo/).
### App Graph
The graph of everything a person can open or do in an app, made of [nodes](#node) and [actions](#action). In Composer
the navigation tree, toolbars, context menus and command palette all read it. See
[App Graph](/docs/plex/guides/app-graph/).
### App Toolkit
`@dxos/app-toolkit`, the package that adds what a workspace app needs on top of the framework: the
[App Graph](#app-graph), layout, settings and the standard [roles](#role). See
[App capabilities](/docs/plex/guides/app-capabilities/).
### Builder extension
A matcher and a connector that add [nodes](#node) to [App Graph](#app-graph). The matcher picks the nodes it
applies to, and the connector returns the nodes to connect to them. See [App Graph](/docs/plex/guides/app-graph/).
### Capability
A typed slot that [modules](#module) contribute values to and other modules read. Plugins extend each other through
capabilities instead of imports. See [Capability](/docs/plex/guides/capability/).
### Filter
The part of a [surface](#surface) contribution that names the [roles](#role) it serves and a guard on the data, such
as "the article role, when the subject is a Note". See [Surfaces](/docs/plex/concepts/surfaces/).
### Handler
An Effect that takes an [operation](#operation)'s input and returns its output. Plugins contribute handlers in a
handler set, separately from the operation's definition. See [Operation](/docs/plex/guides/operation/).
### Module
A part of a [plugin](#plugin) that loads on an [activation event](#activation-event) and contributes
[capabilities](#capability). See [Plugin](/docs/plex/guides/plugin/).
### Node
An item in [App Graph](#app-graph), with an id, a type, data and properties. Its data is usually what it stands
for, such as a space or an ECHO object. Each node has a path of ids from the root, and in Composer the URL is a node
path.
### Operation
A named action with a typed input and output, defined once and run by buttons, the command palette and agents alike.
See [Operations](/docs/plex/concepts/operations/).
### Operation invoker
The part of the framework that runs an [operation](#operation): it looks up the [handler](#handler) by the
operation's key, validates the input and output, and runs the handler.
### Plugin
Metadata and the [modules](#module) a plugin is made of. A plugin does nothing by itself; its modules contribute
everything it adds to the app. See [Plugin](/docs/plex/guides/plugin/).
### Plugin manager
The part of the framework that loads [plugins](#plugin), fires [activation events](#activation-event) and activates
the [modules](#module) waiting on them.
### Role
A named place in an app's UI, such as `Article` for the main content area or `Section` for an object inside a stack.
Each role declares the data it passes to a [surface](#surface). See [Surfaces](/docs/plex/concepts/surfaces/).
### Schema registration
A plugin's contribution to the `Schema` capability, which lists the ECHO types it defines. The app can name, list and
create only objects of registered types. See [Using ECHO in a plugin](/docs/plex/guides/using-echo/).
### Surface
A named place in the UI that plugins fill with components. A surface contribution is a component and a
[filter](#filter); the host asks for a [role](#role) and renders the contributions that match. See
[Surfaces](/docs/plex/concepts/surfaces/).
---
# llms.txt
The PLEX site map and full text for agents, in the llms.txt format.
Point an agent at these files before asking it to work with PLEX. They follow the [llms.txt](https://llmstxt.org/) format and cover every written page of this section.
| File | Contents |
| -------------------------------------------- | ---------------------------------------------------------- |
| [`/plex/llms.txt`](/plex/llms.txt) | Each PLEX page with its summary and a link to its Markdown |
| [`/plex/llms-full.txt`](/plex/llms-full.txt) | Every PLEX page in one file |
Any page is also served as Markdown at its URL with `.md` in place of the trailing slash.