Before using this walkthrough, read Provider Command And Event Flow Guide for the full provider-side model, including history behavior and the rule that MCP activity should be excluded from UI-history requirements.
This guide explains how to add and deploy a new custom action end-to-end across:
It uses nullcommand as a template.
nullcommand WorksProvider baseline class:
provider/src/opamp_provider/command_implementations/command_nullcommand.pyKey parts in that class:
customnullcommandorg.mp3monster.opamp_provider.nullcommandto_custom_message() returning CustomMessageThe provider exposes this command in the UI metadata endpoint (/api/commands/custom) and can queue it.
Current behavior: the consumer includes a built-in handler for org.mp3monster.opamp_provider.nullcommand that logs the dummyValue payload field when this command is executed.
Add a new file in:
provider/src/opamp_provider/command_implementations/command_<your_action>.pyImplement:
CommandObjectInterfaceCommandParameterSchemaInterface (if your action has user parameters)Model it after CommandNullCommand:
custom)get_capability_fqdn()to_custom_message() to encode payload dataget_user_parameter_schema() for UI fieldsUse this naming/location/interface pattern because provider discovery scans opamp_provider.command_implementations, imports modules that match the command* convention, and registers classes that satisfy the command interfaces. The custom command factory then resolves discovered classes dynamically by capability/operation/action, so matching these conventions is what makes your command discoverable and queueable without per-command factory edits.
Provider custom command routing uses wildcard handling for classifier custom, which means you do not add per-command entries in COMMAND_BUILDERS. This matters because queueing still validates (classifier, action) and uses _build_custom_command_payload(...) to emit ServerToAgent.custom_message, so custom commands can flow through one routing path while still being validated.
Restart the provider after code changes because command discovery/registration is built at startup and new modules are not re-discovered live.
If you want state continuity across restart (clients, pending approvals, queued command state), use state persistence:
Global Settings -> Server Settings -> State Persistence), set state_save_folder, retention_count, and autosave_interval_seconds_since_change, then click OK + Save.config/opamp.json by setting provider.state_persistence.enabled to true (the UI currently manages persistence settings but does not toggle this enable flag), then restart the provider once.GET /api/settings/diagnostic and confirm state_persistence_enabled is true.POST /api/settings/state/save.opamp-provider --config-path ./config/opamp.json --restoreAfter restart, verify GET /api/settings/diagnostic again and check state_persistence.restore_status for the restore result.
Add a handler file in:
consumer/src/opamp_consumer/custom_handlers/<your_handler>.pySubclass:
CustomMessageHandlerInterfaceImplement:
get_fqdn() (must exactly match provider capability FQDN)handle_message(...)execute_action(...)Reference implementations:
consumer/src/opamp_consumer/custom_handlers/chatops_command.pyconsumer/src/opamp_consumer/custom_handlers/shutdowncommand.pyconsumer/src/opamp_consumer/custom_handlers/nullcommand.pyDo this because consumer dispatch is capability-driven: handle_custom_message(...) resolves by CustomMessage.capability and executes the matching handler. If your handler FQDN does not exactly match what the provider sends, the message is rejected.
Set in consumer config:
consumer.allow_custom_capabilities: trueThe default client dynamically discovers handlers from:
consumer/src/opamp_consumer/custom_handlersvia build_factory_lookup(...).
Set this because the handler registry stays empty when allow_custom_capabilities is false. Discovery is dynamic, but only from this folder and only for classes implementing CustomMessageHandlerInterface.
Restart the consumer so the new handler module is discovered and loaded into the in-memory lookup created when client instances initialize.
Call:
GET /api/commands/customConfirm your command appears with:
fqdnoperationdisplaynameschema (if defined)This confirms provider discovery worked and that the command is visible to UI/API clients before runtime dispatch testing.
From UI:
Or via API with key/value pairs (include at minimum classifier, operation, capability).
Queueing verifies provider route validation and command normalization in /api/clients/<client_id>/commands.
Check consumer logs for:
execute_action(...) calledThis confirms the end-to-end contract: provider emitted the expected capability/type/data and consumer mapped that capability to the intended handler.
For a new UI-visible custom action that behaves like nullcommand, the current codebase requires these edits:
provider/src/opamp_provider/command_implementations/, because provider module/class discovery is startup-based and convention-driven.consumer/src/opamp_consumer/custom_handlers/ with matching capability FQDN, because custom messages are dispatched by capability and unmatched capabilities are rejected.consumer.allow_custom_capabilities=true in config, because handler discovery/lookup is disabled otherwise.You do not need per-command edits in:
provider/src/opamp_provider/commands.py for custom capability/operation mapping.provider/src/opamp_provider/app.py to register custom actions in COMMAND_BUILDERS.Optional but recommended:
provider/tests for queueing and payload shape.consumer/tests for handler discovery and execute_action(...).