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.gz bundle, and system admins enable, disable and configure it.
The server part runs with full access to the server's data. Treat an app like code you deploy to the server.

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 of plugin.json; their build tool takes it from the latest v* git tag.
  • min_server_version: Antimatter reports 12.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

HookWhen it runs
OnActivate, OnDeactivateWhen the app is enabled or disabled, and when the server starts and stops. Register commands and bots in OnActivate.
OnConfigurationChangeWhen its settings change; load them with p.API.LoadPluginConfiguration(&cfg).
ServeHTTPRequests 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.
ExecuteCommandA command the app registered was run.
MessageWillBePosted, MessageWillBeUpdatedBefore a message is saved: return a changed post, or a reason to reject it.
MessageHasBeenPosted, MessageHasBeenUpdated, MessageHasBeenDeletedAfter the fact, for side effects.
ReactionHasBeenAdded, ReactionHasBeenRemovedReactions.
UserHasJoinedChannel, UserHasLeftChannel, UserHasJoinedTeam, UserHasLeftTeam, ChannelHasBeenCreatedMembership and channel changes. A team is a community.
FileWillBeUploadedBefore a file is stored: change it or reject it.
OnPluginClusterEventA 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 as custom_<app id>_poll_updated.
  • OpenInteractiveDialog: open a dialog.
  • LogInfo, LogError and 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.

MethodWhat 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.

Take 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) and KVSetWithOptions with Atomic: 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 from Client4.getOptions() (from mattermost-redux/client): they carry the headers the server needs to accept a request from the browser.
  • Your own state. registerReducer adds a reducer, stored under state['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 methodWhere it appears in Fusion
registerAppBarComponent, registerChannelHeaderButtonActionThe 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).
registerRightHandSidebarComponentThe 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.
registerChannelHeaderIconIcons in the channel header, after the call buttons; on phones, in the header's More menu.
registerChannelHeaderChipFusion 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).
registerCallButtonActionThe channel header's call button; several providers share an Other ways to call menu.
registerPostDropdownMenuActionThe 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.
registerPostDropdownSubMenuActionA 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.
registerPostActionComponentThe message hover toolbar, before the ⋯ button.
registerPostTypeComponent, registerPostMessageAttachmentComponent, registerPostHeaderComponent, registerPostWillRenderEmbedComponentThe message itself: its body, below its text, after its time, and in place of link previews.
registerChannelHeaderMenuActionThe channel's Channel options menu (⋯ on a channel in the sidebar, or right-click), as items with a plug icon.
registerMainMenuActionThe community menu (the community's name at the top of the channel list), under More actions, grouped by app.
registerSidebarBrowseOrAddChannelMenuActionThe community menu, after Create channel and Create category.
registerFileUploadMethodThe composer's + menu (Add to message), after Upload a file.
registerPostEditorActionComponentThe composer's formatting bar, when formatting is shown.
registerAIActionMenuItemComponentThe composer's AI menu.
registerFileDropdownMenuAction, registerFilePreviewComponentA file's options menu, and the file preview.
registerUserSettingsProfile & settings, one tab per app under the Plugins heading.
registerChannelSettingsTabThe channel settings dialog, under the Apps heading.
registerPopoverUserAttributesComponent, registerPopoverUserActionsComponentThe profile card shown when you click a name or an avatar.
registerLeftSidebarHeaderComponent, registerSidebarChannelLinkLabelComponent, registerChannelIconOverride, registerBottomTeamSidebarComponentThe 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, registerComposerPlaceholderThe start of a channel's history, the top of the message list, the unread line, above the composer, and the composer's placeholder.
registerChannelTypeOptionThe Create channel dialog's channel types.
registerSearchComponentsThe channel header's search box: app scopes appear when it is empty.
registerUserGuideDropdownMenuActionAbout Antimatter (bottom of the app rail), under Help.
registerProductFull 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, registerTranslationsWork as in the classic interface.
registerAdminConsolePlugin, registerAdminConsoleCustomSettingThe 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. registerProductSwitcherMenuItem does nothing in Fusion, which has no product menu (it logs a warning). Use registerPostDropdownMenuAction instead of the first.
  • registerMessageWillBeUpdatedHook doesn't run when a message is edited in Fusion. Check edits on the server with the MessageWillBeUpdated hook instead.
  • registerActionAfterChannelCreation only 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 registerNeedsTeamRoute and registerCustomRoute keep 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');
}
MethodWhat 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), setSubmit and onCancel (back to a plain message, keeping the text).
  • Give setSubmit a 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.
  • placeholder and sendLabel are strings, or functions of react-intl's intl. 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>', or null on 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, or undefined. 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:

  1. Write every string with React Intl, with an ID and its English text: <FormattedMessage id='polls.create' defaultMessage='Create a poll'/> or defineMessages. Prefix the IDs with your app's name.
  2. Extract the English strings into webapp/i18n/en.json (the bundled apps use formatjs extract).
  3. Add one file per language next to it, with the same IDs: fr.json, de.json, pt-BR.json, zh-CN.json…
  4. 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:

CommandWhat it does
make distBuilds the server part for every platform and the web app part, and writes the bundle to dist/<app id>-<version>.tar.gz.
make testRuns the Go and JavaScript tests.
make check-styleLints the code (golangci-lint, ESLint) and checks the TypeScript types.
make deployBuilds, then installs and enables the bundle on a server.
make watch, make deploy-from-watchThe 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 resetEnable, disable, or restart the app on the server.
make logs, make logs-watchShow the app's lines of the server log.
make attachAttaches 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.gz followed by amctl 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.
For quick iterations on a development server, set 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.