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:
- Go — compiled with TinyGo (the examples below use TinyGo)
- Rust — native WASM support via
wasm32-unknown-unknowntarget - C / C++ — via Emscripten or clang with the WASM target
- Zig — built-in WASM target
What plugins can do
- React to ticket lifecycle events — fire when tickets are created, assigned, resolved, closed, etc.
- Make outbound HTTP requests — call external APIs (Slack, Teams, PagerDuty, JIRA, etc.), subject to declared allowed hosts.
- Add replies or internal notes — annotate tickets programmatically.
- Set custom fields on tickets — store arbitrary key/value metadata.
- Render UI panels — inject an iframe panel into the ticket detail view.
- Log messages — written to the host application's log at a configured level.
What plugins cannot do
- Access the database directly
- Read environment variables or the filesystem
- Make network requests to undeclared hosts
- Spawn threads or subprocesses
- Access memory outside the WASM sandbox
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
| Field | Required | Description |
|---|---|---|
id | yes | Reverse-domain unique identifier. Must be stable across versions — changing it creates a new plugin entry. |
name | yes | Human-readable display name shown in the admin panel. |
version | yes | Semantic version string (e.g. "1.2.0"). |
description | yes | One-sentence description shown in the plugin list. |
author | no | Author name and optional email. |
license | no | SPDX license identifier. |
homepage | no | URL to the plugin's documentation or source. |
events | yes | Array 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_hosts | no | Hostnames the plugin may make outbound HTTP requests to. Exact hostnames only — no wildcards, no ports, no schemes. Example: "hooks.slack.com". |
config_schema | no | Map of configuration field names to field definitions. Fields are rendered as a form in the admin panel. |
Config schema field types
| Type | Renders as | |
|---|---|---|
string | Text input | |
string + "secret": true | Password input. Value is encrypted at rest and never returned by the API after saving. | |
boolean | Checkbox | |
integer | Number 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
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
| Event | Extra fields in payload |
|---|---|
ticket.created | — |
ticket.assigned | data.assignee — the new assignee (user or group) |
ticket.status_changed | data.from_status, data.to_status |
ticket.reply_added | data.reply_id, data.body, data.is_internal, data.author |
ticket.note_added | same as reply_added, always with is_internal: true |
ticket.resolved | data.resolution_notes (may be empty string) |
ticket.closed | — |
ticket.reopened | data.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.
| Function | Signature | Description |
|---|---|---|
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
| Function | Signature | Description |
|---|---|---|
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
| Function | Signature | Description |
|---|---|---|
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
| Function | Signature | Description |
|---|---|---|
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.
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:
| Action | Data returned |
|---|---|
getTicket | Full ticket object (same shape as REST API response) |
getCurrentUser | Authenticated user object (id, name, role) |
getCustomFields | Object 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
- No filesystem access — the WASM module cannot open, read, write, or list files on the host system. The only "files" available are WASM linear memory and the host API.
- No environment variables — the host environment is never visible to plugins. Configuration is passed explicitly via the event payload's
configfield. - No direct network access — all outbound HTTP goes through
ohd_http_request, which enforces the allowlist. Requests to undeclared hosts are rejected without making a TCP connection. - No subprocesses — the WASM spec does not provide process creation, and wazero does not import any such function.
- Memory isolation — each plugin module has its own linear memory. Plugins cannot read or write each other's memory or any part of the host process's memory outside the WASM sandbox.
- Separate instances — each plugin is a separate wazero module instance. State does not leak between plugins.
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:
- Events — what events does it receive? A notification plugin only needs
ticket.createdand similar creation/status events. Be suspicious of a notification plugin that subscribes toticket.note_added(internal notes). - Allowed hosts — what external services does it call? Verify that you recognize and trust every host listed.
- Source — where did the plugin come from? First-party plugins from the Open Help Desk organization are reviewed. Third-party plugins should ideally be open-source so you can inspect the source.