Skip to main content

The command registry

A command is a named thing somebody can invoke: Play a random song, Clear the playlist, Publish this draft. Plugins register them, the application decides how they are reached, and the reactor knows about no surface at all.

The registry exists on both tiers — @datalayer/reactor and reactor.commands — because the two halves should not disagree about what a plugin can offer.

Why a registry and not a contribution point

The reactor already has a mechanism for "plugins offer, the host chooses": contribution points. Commands are not one, and the difference is not cosmetic.

A contribution is data the host reads and interprets. A command is behaviour the host invokes without interpreting it. Every host would otherwise reimplement the same three things — look one up by id, run it and catch what it throws, drop the ones whose plugin went away — and each would get the error handling slightly wrong.

Registering a command also gets you every surface at once. The same registration appears in the command palette and in reactor commands list on the terminal, because the registry is in the core rather than in whichever surface happened to need it first.

What a command is

Field
idstable, unique identity — music.playRandom, not the label
namewhat a person reads
descriptionone line, shown beside the name where there is room
octiconan Octicon name
emojifor surfaces with no icon set
categorygroups related commands; the reactor never interprets it
keybindingtext to display; the reactor listens to no keyboard
orderlower sorts first, ties keep registration order
isEnabledwhether it can run right now
executedo the thing; may be async, may throw

Presentation sits beside behaviour deliberately. A palette needs a label, an icon and a description before anything runs, and a command carrying only a function forces every surface to keep a parallel table of labels — which then drifts.

Ids are namespaced by convention and registering one twice is refused, not silently overwritten: two plugins fighting over an id is a bug, and the one that lost would otherwise fail invisibly.

TypeScript

Declare commands on the plugin when they need nothing from the build:

import { definePlugin } from '@datalayer/reactor';

export const ShopPlugin = definePlugin({
name: '@music/shop',
commands: [
{
id: 'music.shop.clearCart',
name: 'Clear the cart',
description: 'Remove every song from the cart',
emoji: '🧹',
category: 'Shop',
isEnabled: () => Object.keys(useCart.getState().lines).length > 0,
execute: () => useCart.getState().clear(),
},
],
});

Register imperatively when a command closes over build output, or appears later:

register(ctx) {
return ctx.registerCommand({
id: 'music.play',
name: 'Play',
execute: () => ctx.state.getOutput()!.player.play(),
});
}

The returned disposer is idempotent, and everything a plugin registered is disposed when it stops or is disabled — so the ordinary case needs no disposer at all.

Reading and running, from the reactor or from React:

reactor.listCommands(); // ordered, with the plugin that registered each
reactor.getCommand('music.play');
await reactor.executeCommand('music.play');
import { useCommands, useReactorPlatform } from '@datalayer/reactor/react';

const commands = useCommands(); // re-reads when the reactor changes

Python

The hook is provide_slash_commands. The host passes the registry as this plugin sees it, so a plugin never names itself:

class CatalogPlugin:
def provide_slash_commands(self, commands) -> None:
commands.add(
"catalog.describe",
"Describe the catalog",
describe_catalog,
description="How many songs there are, and who is on them",
emoji="🎵",
category="Catalog",
)

commands.add(...) is shorthand; commands.register(Command(...)) takes the dataclass when you want to build one up. Reading and running:

platform.list_commands() # Command objects, tenant-filtered
platform.describe_commands() # the same as JSON, for an HTTP palette
await platform.execute_command("catalog.describe")

execute is always awaitable, even for a synchronous handler, so a caller never has to ask which kind it invoked. A handler that takes no parameter is called with none.

Unavailable is not hidden

isEnabled is asked at read time, so Clear the playlist can be greyed out while the playlist is empty. A command that is unavailable is still listed: hiding it makes the application look like it lost a feature, and telling somebody why they cannot do something is more useful than pretending it was never there.

A predicate that raises counts as unavailable rather than taking the palette down with it.

Failure

executeCommand rejects when there is no such command, when it is currently unavailable, or when the command itself throws. It never swallows the error and never takes down the surface that invoked it: the caller decides what a failed command looks like, because only it knows where to say so. The palette, for instance, shows the message and stays open with the query intact.

On the Python tier a command belonging to a plugin the tenant may not use reads as no such command rather than as not allowed — a tenant should not learn what it cannot have.

A plugin may also offer some of its commands to an AI agent, as tools; see Agent tools.