Skip to main content

Authoring a Wasm Notification Plugin

The declarative tier covers roughly 85% of every notification destination in existence, because most of them are one sentence: POST this JSON body to this URL with these headers. This page is about the other 15% - the destinations that need a signature computed, an OAuth exchange, a non-HTTP transport, a payload no template can express, or egress from inside a customer network.

Those are wasm_plugin providers. A package declares its notifiers in a notifications: block in plugin.yaml, the platform validates that block at import, and the agent's Wasm host runs the module when a delivery is dispatched to it.

This page is the authoring reference for the contract between the two. To page Discord or Slack for the first time, see the Notifications Quickstart. For channels, routes, escalation, and the Delivery Log, see How Notifications Work. For the no-code tier, see Declarative Providers. For the plugin sandbox itself - capabilities, permissions, signing, and the import workflow - see Wasm Plugins.

Before you start

  • Use an SDK. serviceradar-sdk-go and serviceradar-sdk-rust both ship the notifier envelopes, the manifest-block builder, and the credential helpers, and both emit exactly the keys the platform validator accepts. See SDKs & Plugin Development.
  • A notifier package is an ordinary signed Wasm plugin package. It is uploaded, approved, and assigned like any other, and it delivers nothing until it is approved with notify:v1 and assigned to an agent.
  • Read What this tier is, and where it stops first. Writing a Wasm plugin for a destination the declarative tier already covers costs you a toolchain, a release cycle, and a signature for no behaviour the platform did not already have.

What this tier is, and where it stops

You wantTier
POST/PUT/PATCH a rendered body to a URLDeclarative
Request signing, an OAuth exchange, a second request, value mappingwasm_plugin. This page
Message threading, attachments, a resolve-update request, an inbound callbackwasm_plugin
A payload the seven-filter template language cannot expresswasm_plugin
Egress from inside a customer networkwasm_plugin on the edge_agent route
A destination ServiceRadar already shipsNothing. Configure a channel

There are exactly three extensibility tiers - native, declarative, and wasm_plugin - plus a built-in stream provider type that operators cannot author. Nothing in this repository ships a first-party notifier bundle: every destination we would ship ourselves is reachable from the platform and is "POST this JSON to this URL", which is the declarative tier's job. The example notifier and its fixture corpus live in the SDKs.

The notifications: manifest block

A package that ships notifiers declares them in plugin.yaml. Each entry describes exactly one notifier.

id: acme-notifier
name: Acme Notifier
version: 1.0.0
entrypoint: run_action
runtime: wasi-preview1
outputs: serviceradar.plugin_result.v1
capabilities: [get_config, log, http_request, "notify:v1"]
permissions:
allowed_domains: [api.acme.example]
allowed_ports: [443]
resources:
requested_memory_mb: 32
requested_cpu_ms: 5000
notifications:
- key: acme_incidents
display_name: Acme Incidents
description: Routes alerts to the Acme incident API
entrypoint: notify_acme
config_schema:
type: object
additionalProperties: false
required: [base_url, api_token]
properties:
base_url:
type: string
title: API base URL
api_token:
type: string
title: API token
secretRef: true
credentialKind: api_token
capabilities: [send, test, resolve_update]
payload_formats: [json]
routes: [control_plane, edge_agent]
credential_requirements:
api_token:
injection_mode: bearer_token
inbound:
enabled: false

The ten keys

The key set is closed. These ten names are the whole contract, and anything else is rejected by name and index rather than ignored.

KeyRequiredDefaultWhat it is
keyyes-Slug naming this notifier inside the package. Lowercase letters, numbers, dots, underscores, hyphens. This is what a NotificationProvider.action_key binds to
display_nameyes-Human label shown when an operator picks a provider
descriptionnoomittedOne or two sentences of prose
entrypointyes-The exported guest function for this notifier. See One export, many notifiers before you rely on it
config_schemanoemptyJSON Schema subset describing the channel configuration form. Validated by the same validator the declarative tier uses
capabilitiesyes-What this notifier can do. Must include both send and test
payload_formatsyes-Rendered formats this notifier accepts. Must be non-empty
routesno[control_plane]Execution routes this notifier supports
credential_requirementsnoemptyNamed credentials and how the host injects them
inboundnodisabledInbound callback configuration

A misspelled key is a setting that quietly did nothing, so the validator refuses the manifest at import instead of shipping a notifier whose credential requirements or callback configuration silently vanished.

Two near-miss spellings were considered during design and are deliberately not accepted, because both are plausible enough that an SDK could emit one and produce a manifest the platform rejects:

Not acceptedSpell itWhy
provider_keykeyThe entry key names the notifier inside the package. The provider key is chosen by the operator when the NotificationProvider row is created, and is not the package's to pick
inbound_callbackinboundinbound_callback is the name of the capability. The block that configures it is inbound

capabilities

CapabilityMeaning
sendDeliver a notification. Mandatory
testDeliver a test notification. Mandatory
resolve_updateSend a follow-up when the alert resolves
inbound_callbackAccept a signed callback from the destination
rich_payloadRender structured rather than plain content
attachmentsAttach files
threadingReply into an existing thread

send and test are mandatory in every tier, so that "test-send before saving" works uniformly and no provider can opt out of being testable. An entry missing either is rejected and the error names which one.

inbound_callback and the inbound block are two halves of one decision, and declaring either alone is refused: an enabled callback without the capability would be unreachable, and the capability without an enabled callback grants nothing.

payload_formats and routes

payload_formats may name slack_blocks, discord_embed, markdown, plain, html, pagerduty_v2, or json. An empty list is refused - a notifier that can render nothing cannot deliver anything.

routes may name control_plane or edge_agent, and defaults to [control_plane], which is the documented recommendation. See The two execution routes.

config_schema

The channel configuration form is generated from this schema, and it is read from the package's current manifest at render time rather than from a copy taken when the provider row was created - so upgrading a package reaches the form without an operator re-creating anything.

Mark a credential field secretRef: true. That is what makes the platform store a reference rather than the value, classify the field as secret in the form, and keep it out of the test-send payload. A field the schema does not mark as secret is ordinary configuration everywhere in the system.

credential_requirements

A map of credential name to a closed, typed requirement object, with at most 16 requirements per notifier. The requirement name normally matches the secretRef field in config_schema; set config_key when it does not.

Common fieldRequiredType and meaning
injection_modeyesOne of the six canonical names in Credentials
requirednoBoolean; defaults to false. A missing required channel credential stops delivery before a grant is issued
config_keynoString naming the channel config field that holds the opaque network-credential reference
ttl_secondsnoPositive integer lifetime for the one-delivery credential grant; defaults to 300
allownoA further narrowing map. Its closed keys are hosts, schemes, methods, paths, and ports; ports are integers and every other member is a string

Mode-specific fields are listed under The six canonical injection modes. Unknown fields are rejected during package import. In particular, do not supply a nested inject map or the northbound-only allowed_hosts, allowed_ports, allowed_methods, allowed_paths, or allowed_schemes aliases. The platform constructs the host wire map only from the validated typed fields. Arbitrary fixed_* literals are also rejected; a plugin can put non-secret fixed values in its own request body, while the one host-generated fixed value required by OAuth is declared exactly as fixed_grant_type: password.

The credential grant inherits the assignment's effective allowed_domains and allowed_ports after package approval and assignment overrides have narrowed them. If the channel config contains a concrete URL, the grant narrows again to that endpoint. The form and OAuth modes instead narrow to their declared exact targets. A notifier therefore needs an explicit manifest permissions scope; credential injection never creates egress authority on its own. For OAuth, both the upstream request host/port and the token-exchange host/port must remain inside the assignment's effective permissions. A package can import with a broad enough permission but later fail closed at delivery if an assignment override removes either endpoint.

inbound

KeyDefaultNotes
enabledfalse
signaturenoneMust be hmac_sha256 when enabled is true
signature_header-Required when the signature is hmac_sha256; it is where the verifier reads the signature from
timestamp_header-
tolerance_seconds300Positive, at most 900
path_suffix-Required when enabled

An enabled callback that signs nothing accepts any caller who guesses the route, so the signed shape is mandatory rather than a default an author can forget, and an unbounded replay window is refused.

The block and the capability are two halves of one thing

A package with notifications: entries must request notify:v1, and a package requesting notify:v1 must declare notifier entries. Either half alone is inert - the agent refuses a notifier without the capability, and the capability with no notifier grants nothing - so the validator refuses both shapes rather than importing something that cannot work.

notify:v1 is enforced, not just declared

notify:v1 is the capability that authorizes notification delivery, and it is checked by the agent, not only by the manifest validator.

That distinction is the whole point. A capability that exists only in an allowlist is unenforced: the control plane can be convinced to dispatch to a plugin that never requested the permission, and nothing on the execution path objects. Two capabilities in the manifest allowlist are exactly that today. notify:v1 is not allowed to become the third, so the host refuses a notification dispatch for an assignment whose capability set omits it, from both entrances to plugin execution. A permission with one guarded entrance is not enforced.

Three properties of the gate are worth designing around:

  1. It reads the assignment, not the package manifest. What reaches the agent is the narrowed capability set: the package's approved capabilities, then the assignment's override. An operator who denies notify:v1 at either step lands at the same denial. Declaring it in plugin.yaml is necessary and not sufficient.
  2. It runs before the module is loaded. A denied plugin is never instantiated, so no part of the delivery request - which carries alert content - reaches guest memory.
  3. It fails closed. An unknown assignment, an absent capability map, and a malformed credential grant all deny.

One export, many notifiers

The host calls the assignment's entrypoint - the one exported function the control plane put on the assignment, taken from the package's top-level entrypoint - and passes action_key through in the invocation envelope. A package that ships several notifiers therefore dispatches on action_key inside that one export.

The per-notifier entrypoint in the notifications: block is validated and recorded, but nothing carries it to the host today. Write it truthfully, and do not build a package that only works if the host honours it.

What the guest sees

The guest reads its invocation through the host config object. It carries an action_invocation envelope with the delivery's identity (delivery_id, channel_id, action_key, provider_key, payload_format, dedupe_key, is_test), the addressing (plugin_assignment_id, plugin_package_id), and the redacted rendered payload, merged with the assignment's own configuration.

A dispatch that cannot be named is refused rather than run: delivery_id, channel_id, and action_key are what let core correlate the result back to the row that is the system of record, and a delivery nobody can report on is worse than one that never ran, because it looks sent.

The command result your plugin submits is passed through unchanged apart from the correlation identity and the schema defaults - the host is a courier, not an author of your result shape. It is also only a wake-up signal. The delivery row is always the system of record, and a lost result is recovered by a bounded periodic sweep rather than by re-deriving state from the command plane. See Receipts for agent-routed deliveries.

Credentials: the guest never sees one

Secret material never enters guest memory and never travels in the plugin parameters. This is a property of the delivery path, not a convention an author is asked to honour:

  • The command payload carries no secrets, no secret_refs, and no channel config. It carries the redacted payload.
  • The assignment's parameters carry a credentialref: sentinel where a credential would be, never the credential.
  • Trusted-host-only material - a webhook URL whose path is itself the secret - travels in a separate host-only field that get_config does not surface.
  • The credential grant that rides on the delivery names a secret; it does not carry one. The host resolves the material and injects it at the HTTP boundary, after the guest has built its request and before it goes out.

So a plugin does not read a token, cannot log one, and cannot leak one into an error message. Both SDKs enforce the same shape on their side: the secret reference is an opaque type that renders a placeholder through every formatting path, and the SDK-generated error message is bounded and stripped of sentinels and action-link URLs.

The six canonical injection modes

injection_mode must be one of exactly these six. Every field shown as required is checked during package import, not deferred until a delivery reaches an agent.

ModeRequired typed fieldsWhat the host does
http_headername; optional schemeSets the named header to the resolved material, prefixed by scheme and one space when supplied
bearer_tokenNone beyond injection_mode; optional name, schemeSets the named header. The normalized defaults are name: Authorization and scheme: Bearer
basic_authNone beyond injection_modeSets HTTP Basic credentials. The selected credential must expose username (or user) and password material fields
querynameAdds the resolved material as the named query parameter
form_urlencodedmethod, host, integer port, path, and at least one field_<material-field>: <form-field> mappingAdds mapped credential material only when the plugin's HTTPS request exactly matches the method, host, port grant, and path
oauth2_password_bearerThe upstream method, host, integer port, and path; token_method: POST, token_host, integer token_port, and token_path; mappings to both username and password; fixed_grant_type: passwordPerforms the exact HTTPS password-token exchange, then sets the upstream request's Authorization: Bearer ... header

For example, field_account_name: username reads the account_name field from the resolved credential and writes it to the username form field. Mapping keys and values are identifiers, not credential literals. Target paths start with / and do not include a query or fragment. Target ports are integers in plugin.yaml; the platform emits only string-valued entries to the host grant, serializing token_port as a decimal string and keeping the upstream port in the grant's egress scope.

Shorthand spellings some other surfaces accept - header, http_basic_auth, query_param, http_query - are not accepted here, deliberately, so that a plugin author never learns a spelling one surface accepts and another rejects. A manifest carrying header fails validation in the platform; a delivery grant carrying header fails at the agent.

No injection mode rewrites a URL path

None of the six touches the URL path. That is not an oversight, and it has one concrete consequence:

Slack and Discord incoming webhooks cannot run on the edge route. Their incoming-webhook URL carries the secret in the path itself, so there is nothing for a header, a bearer token, or a query parameter to inject. ServiceRadar refuses those channels on edge_agent at save time and again at dispatch, and the agent refuses a url_path grant on its own account so a dispatch that got past the control plane some other way still cannot smuggle one through.

Use the bot-token mode for those destinations. A bot token is a header credential, so it injects normally and works on either route.

A URL-path injection mode is out of scope for this version. If it is ever added, it goes into the host first and the manifest allowlist second, never the other way around.

The two execution routes

execution_route is a property of the channel, not of the plugin, and it decides which agent runs the module. There is exactly one Wasm host in ServiceRadar - the runtime inside serviceradar-agent - so a plugin-backed provider always executes on an agent either way:

RouteAgent
control_planethe platform-resident agent deployed alongside core
edge_agentthe site agent named on the channel

Both use the same command, so a plugin authored for a site agent runs unchanged on the platform agent. Moving a channel between the two is a configuration edit plus a plugin assignment, never a repackage. Declare routes: [control_plane] unless your destination genuinely needs the other one.

:edge_agent is only for destinations the platform cannot reach

The edge route exists for exactly one requirement: a destination that is only reachable from inside the customer network - an internal ticketing system, an on-premises chat server, an SMS gateway on a private segment. It is not a performance option and it is not a default.

The reason is the command path. AgentCommandBus is at-most-once with no store-and-forward: if the target agent has no live control session at the moment of dispatch, the command is marked offline and returns an error, and nothing re-drains offline commands when the agent reconnects. This is tracked as forgejo issue #4902 and it is a property of the current command plane, not a transient bug.

An agent-offline reply is therefore retryable, not an immediate failover - a site that was briefly disconnected is not abandoned on the first missed heartbeat - and only when the attempts are exhausted does the delivery take its one failover hop.

An edge-only escalation policy cannot deliver the "this site went dark" page

This is the configuration that silently guarantees no page at exactly the moment one is owed, so it is worth stating plainly:

If every channel an escalation policy can reach is an edge_agent channel, that policy cannot deliver a site-down page. The platform is the component that detects the site going dark, and the agent it would have paged through went dark with it. There is no queue at the agent to drain when it comes back, because the command was never accepted.

Two corollaries:

  • Always give an edge-routed channel a control-plane fallback, unless you genuinely prefer silence to a duplicate.
  • fail_closed disables failover entirely. It is the right setting for a destination whose whole purpose is that it must not be silently substituted, and the wrong setting for anything you expect to be paged by.

The UI warns about the edge-only shape on the policy row, in the policy editor, and on every route bound to the policy, and grades a channel's failover configuration as you edit it. The warning is not cosmetic. See Execution route for the operator-side detail.

Binding a provider to your notifier

Once the package is imported and approved, an operator creates a NotificationProvider of type wasm_plugin that references the package and names one notifier by its action_key. Three rules apply, and each fails loudly:

  • The action_key must be a key declared in that package's notifications: block. An undeclared key is rejected and the error lists what the package actually declares.
  • A wasm_plugin provider without a package reference is rejected.
  • The package must be approved, and its approved capabilities must include notify:v1. Revoking a package disables every provider bound to it.

Then a channel binds to that provider, and delivery follows the ordinary route, policy, and escalation machinery described in Notifications.

Shipping display contracts (optional)

A package may ship declarative display contracts that tell the web UI how to render this notifier's delivery detail and channel health. They are indexed at runtime from the installed package, so a third-party contract renders without a web-ng release. A contract that this release refuses is dropped with a diagnostic listed in the admin package panel rather than failing the import, and a notifier with no contract degrades to the generic view. See Telemetry Display Contracts.

Display contracts describe widgets declaratively. The nine UI-code key names the manifest already refuses on an action - html, raw_html, javascript, js, component, component_ref, live_view, react, ui_code - are refused in a display contract at any depth, so a contract cannot become a second door into shipping markup.

Why a delivery failed

Every failure names itself on the delivery row. The four configuration errors fail permanently on the first attempt rather than consuming the retry budget: no number of retries approves a package, and the useful behaviour is to fail over to a channel that can page.

error_classMeaning
plugin_package_unapprovedThe package is staged, denied, or revoked
notify_capability_deniedThe approved capabilities do not include notify:v1
plugin_assignment_missingThe package is approved but not assigned to that agent
platform_agent_unconfiguredNo platform-resident agent is configured for the control_plane route
agent_offlineThe agent had no control session. Retryable
agent_command_failedThe command could not be dispatched. Retryable
command_receipt_timeoutThe command passed its TTL with no result. Handed back to the retry budget, and terminal once the budget is spent
secret_unavailableA channel credential could not be resolved. Retryable
payload_format_unsupportedThe rendered format is not one the notifier declares

At the agent, an addressing failure is distinct from a capability failure: missing_notification_target (the dispatch named neither an assignment nor a package), notifier_not_assigned (this agent runs no assignment of that package), and ambiguous_notification_target (this agent runs more than one assignment of that package). The last one fails closed on purpose - two assignments of one package are two channel configurations, so picking either would deliver the alert to the wrong destination and record it as sent.