Writing apps (plugins)
Apps add features to Antimatter from the inside: a server part in Go that hooks into the server, and a web app part in React that adds menus, panels, message types and pages. This page shows how an app is laid out, using the bundled Polls app as the example, and what changes in Antimatter's Fusion interface.
How an app works
- The server part is a Go program. The server starts it as a separate process and talks to it over RPC: it calls the app's hooks (a message is about to be posted, a command was run…), and the app calls the server's plugin API (create a post, read a user, store data…). It can also serve HTTP routes under
/plugins/<app id>/. - The web app part is a JavaScript bundle loaded into the browser with the web app. It registers React components and callbacks with the registry: a button in a menu, a panel, a way to draw a message type.
- Either part is optional. An app is installed as a
.tar.gzbundle, and system admins enable, disable and configure it.
Antimatter runs apps written for Mattermost, and the other way round, as long as an app only uses hooks both have. See Hooks only Antimatter has.
The bundle layout
There is no separate starter template: start from a small bundled app such as antimatter-plugin-polls or antimatter-plugin-todo, and change its ID and code. Polls is laid out like this:
antimatter-plugin-polls/
├── plugin.json the manifest
├── Makefile build, test, bundle, deploy
├── build/ the manifest and pluginctl helper tools (Go), setup.mk
├── assets/icon.svg files shipped with the bundle
├── go.mod
├── server/
│ ├── main.go plugin.ClientMain(&Plugin{})
│ ├── plugin.go the Plugin type, OnActivate, ServeHTTP
│ ├── manifest.go generated from plugin.json
│ ├── api.go, command.go, hooks.go, store.go …
│ └── dist/ compiled binaries, one per platform
└── webapp/
├── package.json, webpack.config.js, tsconfig.json
├── i18n/ en.json and one file per language
├── src/index.tsx registers the app with the web app
├── src/manifest.ts generated from plugin.json
├── src/translations.ts
└── dist/main.js the bundle
Optional: a public/ folder, served as is at /plugins/<app id>/public/.
plugin.json
{
"id": "com.example.weather",
"name": "Weather",
"description": "Forecasts in the chat.",
"homepage_url": "https://github.com/example/weather",
"support_url": "https://github.com/example/weather/issues",
"icon_path": "assets/icon.svg",
"version": "1.0.0",
"min_server_version": "12.0.0",
"server": {
"executables": {
"linux-amd64": "server/dist/plugin-linux-amd64",
"linux-arm64": "server/dist/plugin-linux-arm64",
"darwin-amd64": "server/dist/plugin-darwin-amd64",
"darwin-arm64": "server/dist/plugin-darwin-arm64",
"windows-amd64": "server/dist/plugin-windows-amd64.exe"
}
},
"webapp": {"bundle_path": "webapp/dist/main.js"},
"settings_schema": {"settings": []}
}
id: unique and permanent; a reverse domain name is usual. It names the routes, the stored settings and the WebSocket events.version: semantic version. The bundled apps leave it out ofplugin.json; their build tool takes it from the latestv*git tag.min_server_version: Antimatter reports12.0.0.settings_schema: the app's settings page in the System Console (see Settings).
make apply copies the manifest into server/manifest.go and webapp/src/manifest.ts; make manifest-check validates it.
The server part
The server part imports the same Go module as Mattermost apps, github.com/mattermost/mattermost/server/public, from the Go module proxy. Its main only starts the app:
package main
import "github.com/mattermost/mattermost/server/public/plugin"
func main() {
plugin.ClientMain(&Plugin{})
}
The Plugin type embeds plugin.MattermostPlugin, which gives it p.API, and implements the hooks it needs:
type Plugin struct {
plugin.MattermostPlugin
router *mux.Router
}
func (p *Plugin) OnActivate() error {
p.router = p.newRouter()
return p.API.RegisterCommand(&model.Command{
Trigger: "weather",
AutoComplete: true,
AutoCompleteDesc: "Show the forecast",
AutoCompleteHint: "[city]",
})
}
func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
city := strings.TrimSpace(strings.TrimPrefix(args.Command, "/weather"))
return &model.CommandResponse{
ResponseType: model.CommandResponseTypeEphemeral,
Text: "Forecast for " + city + ": sunny",
}, nil
}
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
p.router.ServeHTTP(w, r)
}
Common hooks
| Hook | When it runs |
|---|---|
OnActivate, OnDeactivate | When the app is enabled or disabled, and when the server starts and stops. Register commands and bots in OnActivate. |
OnConfigurationChange | When its settings change; load them with p.API.LoadPluginConfiguration(&cfg). |
ServeHTTP | Requests to /plugins/<app id>/…. The server sets the Mattermost-User-Id header when the request comes from a signed-in user; refuse requests without it unless they are meant to be public. |
ExecuteCommand | A command the app registered was run. |
MessageWillBePosted, MessageWillBeUpdated | Before a message is saved: return a changed post, or a reason to reject it. |
MessageHasBeenPosted, MessageHasBeenUpdated, MessageHasBeenDeleted | After the fact, for side effects. |
ReactionHasBeenAdded, ReactionHasBeenRemoved | Reactions. |
UserHasJoinedChannel, UserHasLeftChannel, UserHasJoinedTeam, UserHasLeftTeam, ChannelHasBeenCreated | Membership and channel changes. A team is a community. |
FileWillBeUploaded | Before a file is stored: change it or reject it. |
OnPluginClusterEvent | A message from the same app on another server of a cluster. |
The full list, with the exact signatures, is in server/public/plugin/hooks.go; the API is in server/public/plugin/api.go. The pluginapi package (server/public/pluginapi) wraps the API in friendlier helpers.
Useful API calls
CreatePost,UpdatePost,SendEphemeralPost: post as the app's bot or as a user.EnsureBotUser(&model.Bot{Username, DisplayName}): create the app's bot once and get its ID.PublishWebSocketEvent("poll_updated", payload, &model.WebsocketBroadcast{ChannelId: id}): send an event to the web app. It arrives ascustom_<app id>_poll_updated.OpenInteractiveDialog: open a dialog.LogInfo,LogErrorand friends: write to the server log, tagged with the app's ID.GetUser,GetChannel,HasPermissionToChannel: check who may do what before acting for a user.
An app runs on every server of a cluster. Keep state in the key-value store or the database, not only in memory.
The AI service
Antimatter's server calls AI models for apps, through the model connections an admin set up (see AI in the admin guide). An app never holds a provider's key: it names the person it acts for and where, and the server checks that this person may use the connection there, applies its limits and budgets, counts the tokens against the person and the app, and audits the call without its content.
| Method | What it does |
|---|---|
AIChat(actingUserID, channelID, teamID, *ai.ChatRequest) | A chat completion: messages, tools, structured output (JSONSchema). |
AIChatStream(…) | The same, streamed: read the stream's Events until the channel closes, or Close it to stop the call. |
AIEmbed(…, *ai.EmbedRequest) | Embeddings of texts. |
AITranscribe(…, *ai.TranscribeRequest) | Speech to text. |
AIDecide(…, *ai.DecideRequest) | Typed questions to a decision model (yes or no, a choice, a score) about a text. |
AIConnectionsForUser(userID, channelID, teamID) | The connections a person may use there, with their models and capabilities, never their keys. |
Each request's ConnectionID picks the connection, and Model one of its models (empty for the connection's default). Operation is a short label for the usage and audit records, never content. Their minimum server version is 11.11.
actingUserID only from a source the server verified: the Mattermost-User-Id header of a request to your ServeHTTP, or the user of a hook's context (a post's author, the user who ran a command). Never take it from a message, a webhook payload, a URL or another app's request: your app would spend someone else's access.These methods and the ai package (server/public/ai) exist only in Antimatter's copy of the module, not in the version published by Mattermost. To use them, build against Antimatter's server/public with a replace line in your go.mod, for example replace github.com/mattermost/mattermost/server/public => ../antimatter/server/public. Such an app only runs on Antimatter.
Giving agents tools
An app offers its own tools to agents with RegisterAITools([]*model.AIToolDefinition): each tool's name, title, description, input JSON schema, and kind (read, write or external_write), which decides whether an agent's policy may run it on its own (see Tools in the admin guide). Permissions are checked on a channel or community argument of the call, or on the server, as the tool needs. When an agent uses the tool, the server calls the ExecuteAITool(c, *model.AIToolExecution) *model.AIToolResult hook with the verified person who asked in UserId: act with their permissions only, never the agent's. Tools are named <app id>.<name>; minimum server version 11.11; these methods and the hook only exist on Antimatter's server/public, like the AI service above. An app whose ID is antimatter, or that starts with mcp., can't register tools.
Storing data
Each app has its own key-value store in the server's database:
KVSet(key, value),KVGet(key),KVDelete(key): values are bytes, usually JSON. Keys are at most 150 characters.KVSetWithExpiry(key, value, seconds): the value disappears after that time.KVCompareAndSet(key, old, new)andKVSetWithOptionswithAtomic: safe updates when several requests or servers write the same key. Polls saves every vote this way and retries on conflict.KVList(page, perPage): the keys, page by page.
The web app part
The bundle registers a class with window.registerPlugin. Its initialize receives the registry and the Redux store:
import manifest from './manifest';
class Plugin {
initialize(registry, store) {
registry.registerTranslations((locale) => translationsFor(locale));
registry.registerPostTypeComponent('custom_poll', PollPost);
registry.registerWebSocketEventHandler(`custom_${manifest.id}_poll_updated`, (msg) => {
store.dispatch(pollUpdated(JSON.parse(msg.data.poll)));
});
registry.registerReconnectHandler(() => store.dispatch(reloadPolls()));
}
}
window.registerPlugin(manifest.id, new Plugin());
- Shared libraries. The web app provides React, ReactDOM, Redux, React Redux and React Intl: list them as webpack
externals(React,ReactDOM,Redux,ReactRedux,ReactIntl) rather than bundling your own copies. See Polls'webpack.config.js. - Calling your server part. Fetch
${window.basename}/plugins/<app id>/…with the options fromClient4.getOptions()(frommattermost-redux/client): they carry the headers the server needs to accept a request from the browser. - Your own state.
registerReduceradds a reducer, stored understate['plugins-<app id>']. - Types. Antimatter's extra registry methods aren't in Mattermost's published types: Polls declares the registry it uses in
webapp/src/types/host.ts.
The registry's methods and their parameters are documented in the code, in webapp/fusion/src/plugins/registry.ts (Fusion) and webapp/channels/src/plugins/registry.ts (classic).
Supporting the Fusion interface
Antimatter has two web interfaces: Fusion, the default, and the classic one. Both load the same app bundles and have the same registry, but Fusion draws many things itself and places plugin components in its own way. Test your app in both.
Detect the interface
Before any app loads, both interfaces set window.antimatterWebUI to 'fusion' or 'classic', and the same value on the page's root element as data-am-web-ui. On Mattermost, neither is set.
export function isFusionUI(): boolean {
return window.antimatterWebUI === 'fusion' || document.documentElement.dataset.amWebUi === 'fusion';
}
The bundled apps use this to draw differently. Polls draws its poll cards in Fusion's style, and in Fusion has the poll written in the composer (a composer mode) instead of a dialog. Calls and Voice channels leave out the sidebar and toast components that Fusion replaces with its own call interface. The AI chat app puts its AI actions in the message menu, as a group under More actions, instead of the hover toolbar. Fusion's own CSS classes (prefixed am-) and its dialog layer (the am-layer element) are what these apps use to match the interface; they are not a documented, stable API.
Where Fusion shows plugin components
| Registry method | Where it appears in Fusion |
|---|---|
registerAppBarComponent, registerChannelHeaderButtonAction | The app rail, the column of icons on the right edge, between your avatar and the About Antimatter button. Channel header buttons go there too, not in the channel header. Hidden when a system admin turns on Disable Apps Bar (ExperimentalSettings.DisableAppBar, off by default). |
registerRightHandSidebarComponent | The right-hand panel. Its header is Fusion's, the same for every app: the app's icon and name (the registered title when it draws only text, otherwise the app rail's label or the manifest's name), then Restore the panel's size (once resized), Open in a new window and Close. A title that draws anything else (a control, a link, a picture) is dropped, so draw your app's own controls in the panel itself, for example in a slim row at its top. People can resize the panel by dragging its edge. See The panel in a new window. |
registerChannelHeaderIcon | Icons in the channel header, after the call buttons; on phones, in the header's More menu. |
registerChannelHeaderChip | Fusion only (feature-detect it): a short status chip beside the conversation's name, for example the Calendar app's next-call chip. Receives {channel} and returns a component, or null for nothing; give it Fusion's look with the am-head-chip class (am-live too while something is on). |
registerCallButtonAction | The channel header's call button; several providers share an Other ways to call menu. |
registerPostDropdownMenuAction | The message's Message actions (⋯) menu, in its More actions submenu; also in the thread panel's Thread actions menu, under More actions. Fusion draws the app's icon before each action, in grey: its own icon for the bundled apps, otherwise the icon the app shows in the app rail, otherwise a neutral one. An element without text at the start of the action's text (an icon the app draws itself) is hidden. |
registerPostDropdownSubMenuAction | A group of entries in the same More actions submenu, after the single actions, set apart by a line, without a heading. No app gets an entry of its own in the message menu. |
registerPostActionComponent | The message hover toolbar, before the ⋯ button. |
registerPostTypeComponent, registerPostMessageAttachmentComponent, registerPostHeaderComponent, registerPostWillRenderEmbedComponent | The message itself: its body, below its text, after its time, and in place of link previews. |
registerChannelHeaderMenuAction | The channel's Channel options menu (⋯ on a channel in the sidebar, or right-click), as items with a plug icon. |
registerMainMenuAction | The community menu (the community's name at the top of the channel list), under More actions, grouped by app. |
registerSidebarBrowseOrAddChannelMenuAction | The community menu, after Create channel and Create category. |
registerFileUploadMethod | The composer's + menu (Add to message), after Upload a file. |
registerPostEditorActionComponent | The composer's formatting bar, when formatting is shown. |
registerAIActionMenuItemComponent | The composer's AI menu. |
registerFileDropdownMenuAction, registerFilePreviewComponent | A file's options menu, and the file preview. |
registerUserSettings | Profile & settings, one tab per app under the Plugins heading. |
registerChannelSettingsTab | The channel settings dialog, under the Apps heading. |
registerPopoverUserAttributesComponent, registerPopoverUserActionsComponent | The profile card shown when you click a name or an avatar. |
registerLeftSidebarHeaderComponent, registerSidebarChannelLinkLabelComponent, registerChannelIconOverride, registerBottomTeamSidebarComponent | The top of a community's channel list, next to a channel's name, a channel's icon, and the bottom of the community rail on the left. |
registerChannelIntro, registerChannelIntroButtonAction, registerChannelToastComponent, registerNewMessagesSeparatorActionComponent, registerChannelComposerBannerComponent, registerComposerPlaceholder | The start of a channel's history, the top of the message list, the unread line, above the composer, and the composer's placeholder. |
registerChannelTypeOption | The Create channel dialog's channel types. |
registerSearchComponents | The channel header's search box: app scopes appear when it is empty. |
registerUserGuideDropdownMenuAction | About Antimatter (bottom of the app rail), under Help. |
registerProduct | Full pages at the product's URL, in the classic page frame, without a product menu: Fusion has none, and lists products nowhere, so link to the page yourself. No bundled app registers a product: Kanban, Playbooks and the AI chat open in their side panels. |
registerRootComponent, registerGlobalComponent, hooks such as registerMessageWillBePostedHook, registerSlashCommandWillBePostedHook, registerMessageWillFormatHook, registerFilesWillUploadHook, registerDesktopNotificationHook, registerWebSocketEventHandler, registerReducer, registerTranslations | Work as in the classic interface. |
registerAdminConsolePlugin, registerAdminConsoleCustomSetting | The System Console, which is the classic one in both interfaces. |
What Fusion doesn't show or run
registerPostDropdownMenuComponent(a component in the message menu),registerPostCardTypeComponent(a post's card in the right-hand panel) are not shown in Fusion's main views.registerProductSwitcherMenuItemdoes nothing in Fusion, which has no product menu (it logs a warning). UseregisterPostDropdownMenuActioninstead of the first.registerMessageWillBeUpdatedHookdoesn't run when a message is edited in Fusion. Check edits on the server with theMessageWillBeUpdatedhook instead.registerActionAfterChannelCreationonly runs from the classic channel creation dialog, which Fusion opens from More options… in its Create channel dialog.- App bar components are shown whatever their
supportedProductIds. - Routes from
registerNeedsTeamRouteandregisterCustomRoutekeep the classic page frame, like products.
Hooks only Antimatter has
These registry methods don't exist on Mattermost. Check that a method exists before calling it, so the app also works there:
if (typeof registry.registerComposerMenuItem === 'function') {
registry.registerComposerMenuItem({
text: 'Create a poll',
icon: <i className='icon icon-chart-bar'/>,
fusionIcon: 'poll',
action: ({channelId, rootId}) => openPollCreation(channelId, rootId),
});
} else {
registry.registerChannelHeaderButtonAction(icon, (channel) => openPollCreation(channel.id), 'Create a poll', 'Create a poll');
}
| Method | What it adds |
|---|---|
registerComposerMenuItem({text, icon, fusionIcon, action, shouldRender}) | An item in the message box's + menu, in both interfaces. action receives {channelId, rootId}. |
registerEmojiPickerTab({id, label, icon, fusionIcon, order, component, shouldRender, replacesGifPicker}) | A tab of the message box's emoji picker, such as GIFs or stickers. In Fusion, tabs with an order below 10 come before the Emoji tab. The component receives channelId, rootId, filter, onFilterChange, onSelectDone, insertText, isFusion and, in Fusion, pickerKit (see below). |
registerChannelViewPanel({matcher, component}) | A panel between the channel header and the messages, for the channels matcher(state, channel) selects. It can hide the messages and the composer (setMessagesVisible), as voice channels do. |
registerSidebarChannelFooterComponent(component) | A line under a channel in the sidebar, such as the people in its call. It receives the channel. |
registerIntegrationsOption({title, description, fusionIcon, action, shouldRender}) | Fusion only: an entry on the Integrations pages, next to custom emoji. The GIFs app adds its custom GIFs and stickers there. |
Add and manage views in the emoji picker
In Fusion, an emoji picker tab gets a pickerKit prop: the components the Emoji tab uses for its own add and manage views, so that a tab's views look the same and stay inside the picker. The GIFs and Memes apps use it to add and manage GIFs, stickers and meme templates. It holds version (1), the hooks usePickerView and useOnEscape, and the components PickerViewButtons (the + and manage buttons beside the search box), PickerViewFrame (a view with its back arrow), PickerNotice, PickerField, PickerSearch, PickerDropZone and PickerManageList. Escape goes back from a view to the tab's grid. Check that the prop is there: other hosts leave it out, and the tab keeps its own dialogs then. The types are in webapp/fusion/src/types/store/plugins.ts (EmojiPickerTabProps) and the components in webapp/fusion/src/fusion/popovers/picker_views.tsx.
Composer modes
In Fusion, registerComposerMode({id, label, fusionIcon, placeholder, sendLabel, component, shouldRender}) adds another way of writing a message to the composer, as Polls does for a poll. The mode is offered in the composer's + menu under its label. Once picked, the composer keeps its text box (with the mode's placeholder: the poll's question), shows your component under it, and a bar naming the mode with a Cancel button; Send takes the sendLabel and runs your submit instead of posting the text.
- The component receives
channelId,rootId(the thread, in a reply box),message(the composer's text),setSubmitandonCancel(back to a plain message, keeping the text). - Give
setSubmita function that is called with the composer's text and resolves to nothing (or an empty string) once sent: the composer then empties and goes back to a plain message. Resolve to a string to show it as an error in place. placeholderandsendLabelare strings, or functions of react-intl'sintl.shouldRender(state, {channelId, rootId})decides whether a composer offers the mode.- To open the mode from elsewhere, such as a slash command without arguments:
window.dispatchEvent(new CustomEvent('am-composer-mode', {detail: {id, channelId, rootId}})).
Only Fusion has composer modes: check that the registry has the function, and keep your own dialog otherwise. The method returns the mode's id, and the mode goes when the app is removed.
The panel in a new window
Fusion gives the component of registerRightHandSidebarComponent two more props, so that Open in a new window opens the panel where it was rather than at the app's home:
setPanelState(state): call it when the panel changes place, with a short string your app defines, such as'note/<id>'or'board/<id>/card/<id>', ornullon its home. It goes into the window's address, so never put a secret in it. A state longer than 512 characters, or with control characters, is dropped.initialPanelState: in the new window, the state the panel had, orundefined. Treat it as untrusted input: fetch the item again as the signed-in person, and show the app's home or a not-found view if they can't open it.
The state is forgotten when the panel closes or another app's panel takes its place. To open a panel's window yourself, popoutRhsPlugin (also in window.WebappUtils.popouts) takes the state as an optional fifth argument. Notes, Whiteboard, Todo, Kanban and Playbooks use this.
fusionIcon names an icon of Fusion's icon set (for example poll, sticker, calendar or image; see webapp/fusion/src/fusion/components/icon). Fusion shows it instead of icon, so the item matches the others.
Settings
settings_schema in plugin.json describes the app's page in System Console › Plugins:
"settings_schema": {
"header": "Forecasts come from the weather service below.",
"settings": [
{"key": "ServiceURL", "display_name": "Service URL", "type": "text", "default": "https://api.example.com"},
{"key": "Units", "display_name": "Units", "type": "dropdown", "default": "metric",
"options": [{"display_name": "Celsius", "value": "metric"}, {"display_name": "Fahrenheit", "value": "imperial"}]},
{"key": "EnableAlerts", "display_name": "Severe weather alerts", "type": "bool", "default": false,
"help_text": "Post alerts in the channels that follow a city."}
]
}
Setting types: bool, text, longtext, number, dropdown, radio, generated (a random secret), username, and custom (drawn by a component the web app part registers with registerAdminConsoleCustomSetting). Settings can be grouped in sections.
The server part reads them into a struct whose fields match the keys:
type configuration struct {
ServiceURL string
Units string
EnableAlerts bool
}
func (p *Plugin) OnConfigurationChange() error {
var cfg configuration
if err := p.API.LoadPluginConfiguration(&cfg); err != nil {
return err
}
p.setConfiguration(&cfg)
return nil
}
Admins can also set them in the configuration, under PluginSettings.Plugins.<app id>, with keys in lower case (see the admin guide).
Translations
Antimatter is translated into 37 languages, and the bundled apps are too. The web app part does it like this:
- Write every string with React Intl, with an ID and its English text:
<FormattedMessage id='polls.create' defaultMessage='Create a poll'/>ordefineMessages. Prefix the IDs with your app's name. - Extract the English strings into
webapp/i18n/en.json(the bundled apps useformatjs extract). - Add one file per language next to it, with the same IDs:
fr.json,de.json,pt-BR.json,zh-CN.json… - Give them to the web app, which asks for the user's language:
import fr from '../i18n/fr.json'; import ptBR from '../i18n/pt-BR.json'; const catalogs = {fr, 'pt-BR': ptBR}; export function translationsFor(locale) { return catalogs[locale] || catalogs[locale.split('-')[0]] || {}; } registry.registerTranslations(translationsFor);
A missing string falls back to its English defaultMessage. For strings the server part sends (command answers, bot messages), the pluginapi/i18n package loads translation files shipped in the bundle (for example assets/i18n) and gives a localizer in each user's language.
Build, install and debug
You need Go and Node.js (the version in the app's .nvmrc). In the app's folder:
| Command | What it does |
|---|---|
make dist | Builds the server part for every platform and the web app part, and writes the bundle to dist/<app id>-<version>.tar.gz. |
make test | Runs the Go and JavaScript tests. |
make check-style | Lints the code (golangci-lint, ESLint) and checks the TypeScript types. |
make deploy | Builds, then installs and enables the bundle on a server. |
make watch, make deploy-from-watch | The first rebuilds the web app part each time you save; the second bundles what it built and installs it on the server. |
make enable, make disable, make reset | Enable, disable, or restart the app on the server. |
make logs, make logs-watch | Show the app's lines of the server log. |
make attach | Attaches the Delve debugger to the running server part (build with AM_DEBUG=1 first). |
make deploy and the other server commands use a local mode socket when they find one (/var/tmp/antimatter_local.socket, or AM_LOCALSOCKETPATH): run them on the server's machine with local mode on. Otherwise they sign in to the server given by MM_SERVICESETTINGS_SITEURL with AM_ADMIN_TOKEN, or AM_ADMIN_USERNAME and AM_ADMIN_PASSWORD. The build tools don't read an AM_ name for the site URL yet.
Install a bundle by hand
- A system admin allows uploads with
AM_PLUGINSETTINGS_ENABLEUPLOADS=true(off by default; there is no System Console switch). - Then System Console › Plugins › Plugin Management › Upload Plugin, or
amctl plugin add weather.tar.gzfollowed byamctl plugin enable com.example.weather. - If the server has Require Plugin Signature on, it only accepts bundles signed with a key it trusts; see Installing other apps.
AM_SERVICESETTINGS_ENABLEDEVELOPER=true on the server: errors then carry their details. The bundled apps' Makefiles build the server part for your platform only when MM_SERVICESETTINGS_ENABLEDEVELOPER is set in your shell.