Plugin Development

Build WASM plugins that react to ticket events, call external APIs, and add custom UI panels.

Open Help Desk plugins are WebAssembly modules that run sandboxed inside the application process via wazero. They communicate with the host through a defined set of imported and exported functions.

Plugins can be written in any language that compiles to WASM with a C-compatible ABI. Well-supported options include:

What plugins can do

What plugins cannot do

Plugin manifest

Every plugin must embed a JSON manifest in a WASM custom section named ohd_manifest. This declares the plugin's identity, the events it subscribes to, the permissions it needs, and the configuration fields it exposes in the admin UI.

{
  "id":          "com.example.slack-notifications",
  "name":        "Slack Notifications",
  "version":     "1.2.0",
  "description": "Posts a Slack message when a ticket is created or resolved.",
  "author":      "Example Corp <support@example.com>",
  "license":     "MIT",
  "homepage":    "https://github.com/example/ohd-slack-plugin",

  "events": [
    "ticket.created",
    "ticket.resolved"
  ],

  "permissions": {
    "http": {
      "allowed_hosts": ["hooks.slack.com"]
    }
  },

  "config_schema": {
    "webhook_url": {
      "type":        "string",
      "label":       "Slack Webhook URL",
      "description": "Incoming webhook URL from your Slack app configuration.",
      "required":    true,
      "secret":      true
    },
    "channel": {
      "type":        "string",
      "label":       "Default channel",
      "description": "Channel to post to (e.g. #support). Overrides the webhook's default.",
      "required":    false,
      "default":     "#support"
    },
    "mention_on_critical": {
      "type":        "boolean",
      "label":       "Mention @channel for Critical tickets",
      "default":     false
    }
  }
}

Manifest fields

FieldRequiredDescription
idyesReverse-domain unique identifier. Must be stable across versions — changing it creates a new plugin entry.
nameyesHuman-readable display name shown in the admin panel.
versionyesSemantic version string (e.g. "1.2.0").
descriptionyesOne-sentence description shown in the plugin list.
authornoAuthor name and optional email.
licensenoSPDX license identifier.
homepagenoURL to the plugin's documentation or source.
eventsyesArray of event names to subscribe to. Receiving an event you're not subscribed to is impossible — the host filters before calling the plugin.
permissions.http.allowed_hostsnoHostnames the plugin may make outbound HTTP requests to. Exact hostnames only — no wildcards, no ports, no schemes. Example: "hooks.slack.com".
config_schemanoMap of configuration field names to field definitions. Fields are rendered as a form in the admin panel.

Config schema field types

TypeRenders as
stringText input
string + "secret": truePassword input. Value is encrypted at rest and never returned by the API after saving.
booleanCheckbox
integerNumber input
select + "options": [...]Dropdown

Embedding the manifest

In TinyGo, embed the manifest as a custom WASM section using a linker flag:

tinygo build -o plugin.wasm \
  -target wasm \
  -scheduler none \
  -ldflags '-X main.manifestJSON=@manifest.json' \
  ./

Or embed it directly in the Go source using a custom section annotation (requires TinyGo ≥ 0.32):

//go:wasmexport ohd_manifest
var manifestSection = []byte(`{ ... }`)  // content is the manifest JSON

Event hooks

The host calls the plugin's exported on_event function for each subscribed event. The function receives a pointer and byte length for a JSON-encoded event payload in WASM linear memory, reads it, and performs its work.

Exported function

//go:export on_event
func OnEvent(ptr uint32, length uint32)

Event payload structure

{
  "event":       "ticket.created",
  "occurred_at": "2026-04-05T14:00:00Z",

  "ticket": {
    "id":          "01JQXYZ...",
    "subject":     "New laptop request",
    "description": "I need a replacement for the one that was stolen.",
    "status":      "New",
    "priority":    "Medium",
    "category":    "Hardware",
    "type":        "Laptop",
    "item":        null,
    "submitter":   { "id": "...", "name": "Sam Ortiz", "email": "sam@example.com" },
    "assignee":    null,
    "created_at":  "2026-04-05T14:00:00Z",
    "url":         "https://helpdesk.example.com/tickets/01JQXYZ..."
  },

  "actor": {
    "id":   "01JQABC...",
    "name": "Sam Ortiz",
    "role": "user"
  },

  "config": {
    "webhook_url": "https://hooks.slack.com/services/...",
    "channel":     "#support",
    "mention_on_critical": false
  }
}

The config field contains the plugin's saved configuration values. Secret fields are decrypted before being passed into the WASM sandbox. Your plugin can access them like any other config value.

Event-specific additions

EventExtra fields in payload
ticket.created
ticket.assigneddata.assignee — the new assignee (user or group)
ticket.status_changeddata.from_status, data.to_status
ticket.reply_addeddata.reply_id, data.body, data.is_internal, data.author
ticket.note_addedsame as reply_added, always with is_internal: true
ticket.resolveddata.resolution_notes (may be empty string)
ticket.closed
ticket.reopeneddata.reason (may be empty)

Return value

on_event has no return value. If your handler encounters an error, log it with ohd_log. The host does not inspect the return value — it only tracks whether the function returned without trapping (panicking).

Host API reference

The host provides a set of functions imported into the ohd WASM namespace. These are the only I/O operations available to the plugin.

Memory management

Exchanging data between the plugin and host requires writing to and reading from WASM linear memory. The plugin allocates buffers for data it wants to hand to the host, and the host writes response data to buffers allocated by the plugin.

FunctionSignatureDescription
ohd_alloc (size u32) → ptr u32 Allocate size bytes in WASM linear memory. Returns a pointer. The caller is responsible for freeing.
ohd_free (ptr u32, size u32) Free a previously allocated buffer.

Logging

FunctionSignatureDescription
ohd_log (ptr u32, len u32, level u32) Write a UTF-8 log message to the host application's structured log. Level values: 0=debug, 1=info, 2=warn, 3=error. Output is tagged with the plugin ID.

HTTP requests

FunctionSignatureDescription
ohd_http_request (req_ptr u32, req_len u32, resp_ptr_out u32, resp_len_out u32) → status u32 Make an outbound HTTP request. The request is a JSON object written to WASM memory. The response body is written back by the host; its pointer and length are stored at resp_ptr_out and resp_len_out. Returns the HTTP status code, or 0 on network error.

HTTP request JSON format

{
  "method":  "POST",
  "url":     "https://hooks.slack.com/services/T.../B.../...",
  "headers": {
    "Content-Type": "application/json"
  },
  "body":    "{\"text\": \"New ticket: Laptop won't power on\"}"
}

Requests to hosts not declared in the manifest's allowed_hosts are rejected immediately — no network connection is made — and the function returns 403.

Ticket operations

FunctionSignatureDescription
ohd_add_reply (ptr u32, len u32) → status u32 Add a reply or internal note to a ticket. Payload is a JSON object. Returns 0 on success, non-zero on error.
ohd_set_custom_field (ptr u32, len u32) → status u32 Set a custom field key/value pair on a ticket. Returns 0 on success.

ohd_add_reply payload

{
  "ticket_id":   "01JQXYZ...",
  "body":        "PagerDuty incident #12345 created: https://...",
  "is_internal": true
}

ohd_set_custom_field payload

{
  "ticket_id": "01JQXYZ...",
  "key":       "pagerduty_incident_id",
  "value":     "12345"
}

Custom fields are stored as string key/value pairs associated with the ticket. They are visible to plugins via the ticket object in event payloads (under ticket.custom_fields) and are shown in the ticket detail view as metadata rows.

UI panels

A plugin can inject a panel into the ticket detail view. The panel is rendered as an <iframe> that the plugin's server renders. Communication between the iframe content and the host page uses window.postMessage.

Declaring a panel

Add a ui_panel section to the manifest:

{
  "ui_panel": {
    "title":  "JIRA Issue",
    "height": 140
  }
}

height is the fixed iframe height in pixels. The panel cannot be resized by the iframe content after mount.

Providing the panel URL

The host calls the plugin's exported render_panel function when loading the ticket detail view. The function receives the ticket ID as a UTF-8 string and returns a URL the host renders in the iframe.

//go:export render_panel
func RenderPanel(ticketIDPtr, ticketIDLen uint32) (urlPtr, urlLen uint32)
//go:export render_panel
func renderPanel(ticketIDPtr, ticketIDLen uint32) (uint32, uint32) {
    ticketID := readString(ticketIDPtr, ticketIDLen)
    url := "https://plugin-server.example.com/panel?ticket=" + ticketID
    return writeString(url)
}

The URL's hostname must be listed in permissions.http.allowed_hosts in the manifest. The iframe's sandbox attribute allows scripts and same-origin access, but blocks top-level navigation, form submission to other origins, and popups.

postMessage protocol

The iframe can send messages to the host page to request data or trigger actions.

Request ticket data:

// iframe → parent
window.parent.postMessage({
  type:      "ohd:request",
  requestId: "req-1",
  action:    "getTicket"
}, "*");

// parent → iframe (response)
{
  type:      "ohd:response",
  requestId: "req-1",
  data:      { /* full ticket object */ }
}

Available actions:

ActionData returned
getTicketFull ticket object (same shape as REST API response)
getCurrentUserAuthenticated user object (id, name, role)
getCustomFieldsObject with all custom fields set on the ticket by any plugin

The host responds to all messages with the same requestId so the iframe can correlate multiple in-flight requests.

Example plugin — Slack notifications

A complete plugin in Go (TinyGo) that posts to Slack when a ticket is created or resolved.

Project structure

slack-notifications/
  main.go          -- plugin logic
  manifest.json    -- plugin manifest
  Makefile         -- build commands

manifest.json

{
  "id":          "com.example.slack-notifications",
  "name":        "Slack Notifications",
  "version":     "1.0.0",
  "description": "Posts to Slack when tickets are created or resolved.",
  "events":      ["ticket.created", "ticket.resolved"],
  "permissions": {
    "http": { "allowed_hosts": ["hooks.slack.com"] }
  },
  "config_schema": {
    "webhook_url": {
      "type": "string", "label": "Webhook URL",
      "required": true, "secret": true
    },
    "channel": {
      "type": "string", "label": "Channel",
      "default": "#support"
    }
  }
}

main.go

package main

import (
    "encoding/json"
    "fmt"
    "unsafe"
)

// ── Host imports ──────────────────────────────────────────────────────────────

//go:wasmimport ohd ohd_log
func hostLog(ptr, length, level uint32)

//go:wasmimport ohd ohd_http_request
func hostHTTP(reqPtr, reqLen, respPtrOut, respLenOut uint32) uint32

// ── Helpers ───────────────────────────────────────────────────────────────────

func ptrLen(b []byte) (uint32, uint32) {
    if len(b) == 0 {
        return 0, 0
    }
    return uint32(uintptr(unsafe.Pointer(&b[0]))), uint32(len(b))
}

func readBytes(ptr, length uint32) []byte {
    b := make([]byte, length)
    copy(b, unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), length))
    return b
}

func logf(level uint32, format string, args ...any) {
    msg := []byte(fmt.Sprintf(format, args...))
    p, l := ptrLen(msg)
    hostLog(p, l, level)
}

func httpPost(url, contentType, body string) uint32 {
    req, _ := json.Marshal(map[string]any{
        "method":  "POST",
        "url":     url,
        "headers": map[string]string{"Content-Type": contentType},
        "body":    body,
    })
    p, l := ptrLen(req)
    var respPtr, respLen uint32
    return hostHTTP(p, l,
        uint32(uintptr(unsafe.Pointer(&respPtr))),
        uint32(uintptr(unsafe.Pointer(&respLen))),
    )
}

// ── Types ─────────────────────────────────────────────────────────────────────

type Event struct {
    Event  string  `json:"event"`
    Ticket Ticket  `json:"ticket"`
    Config Config  `json:"config"`
}

type Ticket struct {
    ID      string `json:"id"`
    Subject string `json:"subject"`
    Status  string `json:"status"`
    URL     string `json:"url"`
}

type Config struct {
    WebhookURL string `json:"webhook_url"`
    Channel    string `json:"channel"`
}

// ── Handler ───────────────────────────────────────────────────────────────────

//go:export on_event
func onEvent(ptr, length uint32) {
    var ev Event
    if err := json.Unmarshal(readBytes(ptr, length), &ev); err != nil {
        logf(3, "unmarshal error: %s", err)
        return
    }

    var text string
    switch ev.Event {
    case "ticket.created":
        text = fmt.Sprintf(":ticket: *New ticket*: <%s|%s>", ev.Ticket.URL, ev.Ticket.Subject)
    case "ticket.resolved":
        text = fmt.Sprintf(":white_check_mark: *Resolved*: <%s|%s>", ev.Ticket.URL, ev.Ticket.Subject)
    default:
        return
    }

    payload, _ := json.Marshal(map[string]any{
        "channel": ev.Config.Channel,
        "text":    text,
    })

    status := httpPost(ev.Config.WebhookURL, "application/json", string(payload))
    if status != 200 {
        logf(2, "slack returned HTTP %d", status)
    }
}

func main() {}

Makefile

.PHONY: build

build:
	tinygo build \
	  -o slack-notifications.wasm \
	  -target wasm \
	  -scheduler none \
	  .

# Embed the manifest into the compiled binary
embed:
	wasm-opt --enable-bulk-memory \
	  -o slack-notifications.wasm \
	  slack-notifications.wasm
	# Use a tool like wasm-custom-section to embed manifest.json
	# into the ohd_manifest custom section

The examples directory in the repository contains complete, buildable plugins including the Slack example above, a PagerDuty integration, and a JIRA issue linker.

Sandbox & security

Every plugin runs in a strict WASM sandbox enforced by wazero. The sandbox provides strong isolation between plugins and from the host process.

Isolation guarantees

Execution timeouts

Event handlers have a maximum execution time of 5 seconds. If a handler does not return within 5 seconds, wazero terminates the execution and the event is logged as timed out. The ticket operation that triggered the event has already completed — plugin execution is asynchronous from the user's perspective.

Error handling

Plugin errors (panics, traps, timeouts) are caught by the host and logged. They do not crash the application or affect other plugins. Each plugin's error count is tracked and visible in the admin panel under Admin → Plugins → [plugin name].

A plugin that errors on more than 50% of event deliveries in a 10-minute rolling window is automatically disabled, and an in-app notification is sent to admins. The threshold is configurable under Admin → Settings → Plugin error threshold. The plugin can be manually re-enabled after fixing the issue.

Reviewing a plugin before enabling

Before enabling a third-party plugin, review its declared manifest:

← MCP Server Contributing →