Skip to content

Trimus Plugin System

This guide covers the Trimus plugin system -- architecture, protocol, API, and how to build your own plugins.

Overview

Trimus's plugin system lets you extend the control plane with custom tools, event handlers, scheduled jobs, and webhook endpoints. Plugins run as separate OS processes that communicate with the Trimus host over JSON-lines (newline-delimited JSON) via stdin/stdout. This subprocess model provides:

  • Process isolation -- a crashing plugin cannot bring down the host.
  • Language agnosticism -- any language that reads stdin and writes stdout can implement a plugin (though this guide focuses on Go).
  • Security boundaries -- plugins run in their own process space and access host services through a controlled protocol.

The plugin system is implemented in internal/plugins/ and exposed through REST API endpoints and an HTMX management UI.

Two security layers gate a plugin before and during execution, on top of the subprocess isolation above:

  • An approval state machine (pendingallowed/banned) — an admin must explicitly allow a plugin before it can start, and the allowed binary's SHA-256 hash is verified on every start. See Approval & binary integrity.
  • A Docker sandbox with a read-only root, no network, and resource caps wraps each plugin process by default. See The Docker sandbox.

Read both before deploying a plugin to production.

Plugin Lifecycle

Plugins follow a state machine tracked in the database (plugins.state column):

                    ┌──────────┐
                    │installed │ ← initial state after registration
                    └────┬─────┘
                         │ POST /start
                    ┌──────────┐
                    │  ready   │ ← plugin sent "ready" message with manifest
                    └────┬─────┘
                         │ host marks running
                    ┌──────────┐
              ┌────►│ running  │◄────────────────┐
              │     └──┬───┬───┘                  │
              │        │   │                      │
              │  POST  │   │ error              POST
              │/restart│   ▼                  /restart
              │     ┌──────────┐                  │
              │     │ errored  │──────────────────┘
              │     └──────────┘
              │        │
              │  POST  │ POST /stop
              │/start  ▼
              │     ┌──────────┐
              └─────│ stopped  │
                    └────┬─────┘
                         │ disable
                    ┌──────────┐
                    │ disabled │
                    └────┬─────┘
                         │ DELETE
                    ┌────────────┐
                    │uninstalled │
                    └────────────┘

States:

State Description
installed Plugin registered in the database but never started
ready Subprocess started, sent ready message with manifest
running Actively processing tools, events, and jobs
stopped Gracefully shut down via shutdown message
errored Subprocess exited unexpectedly or failed to initialize
disabled Administratively disabled
uninstalled Marked for removal

State transitions are recorded in the plugin_events table for auditing.

The state column above is the lifecycle state (is the process running?). It is distinct from the approval state (pending/allowed/banned, the approval_state column) covered next, which governs whether the plugin is permitted to start at all.

Approval & binary integrity

Source: internal/handler/plugin_approval.go (the transitions) and internal/plugins/host.go (checkPluginApproval, the per-start gate). This closes SEC-012: an admin must vouch for a plugin binary before it runs, and a binary swapped on disk after approval cannot run silently.

The approval state machine

Every plugin has an approval_state, separate from its lifecycle state:

                  POST /allow (snapshots binary SHA-256)
        ┌───────────────────────────────────────────────┐
        │                                                ▼
   ┌─────────┐   POST /ban    ┌─────────┐         ┌──────────┐
   │ pending │───────────────►│ banned  │         │ allowed  │
   └─────────┘                └─────────┘         └────┬─────┘
        ▲                          │                   │
        │   POST /rescind-ban      │                   │ binary hash mismatch
        └──────────────────────────┘                   │ on next StartPlugin
        ▲                                               │ (auto-revert + audit)
        └───────────────────────────────────────────────┘
Endpoint From → To Effect
POST /api/plugins/{id}/allow pending/bannedallowed Computes the on-disk binary's SHA-256 and stores it as approved_binary_hash.
POST /api/plugins/{id}/ban any → banned Plugin refuses to start.
POST /api/plugins/{id}/rescind-ban bannedpending Lifts the ban; a fresh allow is needed to run again.

A newly registered plugin is pending. The UI equivalents are POST /plugins/{id}/ui/allow|ban|rescind-ban.

These approval-transition endpoints gate on admin role inside the handler, not via route middleware — deliberately, so a denied attempt still writes a denied audit row before the 403. (Placing them under RequireRole(admin) middleware would short-circuit the request and skip that audit row.) Every transition — allow, ban, rescind, auto-revert, denied — is recorded; read it at GET /api/companies/{companyId}/plugins/audit (admin) or the company's plugin audit page.

The per-start integrity gate

On every StartPlugin, checkPluginApproval runs before the process spawns:

  1. banned → refuse: plugin ... is banned and cannot be started.
  2. pending → refuse: plugin ... is pending approval — an admin must Allow it.
  3. allowed → re-hash the binary at binary_path and compare with approved_binary_hash:
  4. Match → start proceeds.
  5. Mismatchauto-revert to pending, write an audit row recording approved vs actual hashes, and refuse: plugin ... binary hash changed since approval (auto-reverted to pending; re-approve to re-enable).

So replacing the binary on disk after approval doesn't get you a running plugin — it gets you an audit trail and a refusal. Re-allow to snapshot and trust the new binary.

If no approval DB is wired (test-only configuration), the gate logs a one-time warning and proceeds. In production (cmd/server/main.go wires it), the gate is always active.

The Docker sandbox

Source: internal/plugins/sandbox.go. Closes SEC-011 (filesystem isolation) and SEC-020 (resource limits). By default every plugin process runs inside a hardened Docker container — the same profile the heartbeat sandbox adapters use.

Container shape

docker run -i --rm
  --read-only --tmpfs /tmp:rw,nosuid,size=64m
  --network none
  --security-opt no-new-privileges
  --memory <TRIMUS_PLUGIN_MEMORY_LIMIT default 256m>
  --memory-swap <same as --memory>          # SEC-020: blocks swap-based bypass
  --cpus <TRIMUS_PLUGIN_CPU_LIMIT default 0.5>
  --pids-limit <TRIMUS_PLUGIN_PIDS_LIMIT default 100>
  -v <host binary path>:/plugin/binary:ro   # binary mounted read-only
  -v <workspace>/<plugin_id>:/workspace:rw
  -e TRIMUS_PLUGIN_ID=<uuid> -e TRIMUS_COMPANY_ID=<uuid>
  -e TRIMUS_PLUGIN_WORKSPACE=/workspace
  <TRIMUS_PLUGIN_SANDBOX_IMAGE default alpine:3.20>
  /plugin/binary

docker run -i keeps stdin open so the existing stdin/stdout JSON-lines IPC contract works unchanged. The plugin binary is mounted read-only (the file only, not its directory) so a malicious plugin can't rewrite its own binary mid-run to evade later integrity checks.

Why --network none

The container has no network. A plugin that needs to call out does so through the host's http_fetch service (see Host Services), which is SSRF-guarded and gated by the plugin's host_capabilities allowlist. This makes "deny by default, opt in per plugin" the network posture, rather than giving every plugin unrestricted egress.

Configuration

Env var Default Meaning
TRIMUS_PLUGIN_SANDBOX (sandbox on) Set to disabled to run plugins as bare host subprocesses. Logged as a security warning.
TRIMUS_PLUGIN_SANDBOX_IMAGE alpine:3.20 Base image. Must contain whatever the plugin needs at runtime.
TRIMUS_PLUGIN_WORKSPACE_ROOT /var/lib/trimus/plugins Host dir under which per-plugin /workspace mounts live.
TRIMUS_PLUGIN_MEMORY_LIMIT 256m --memory (and --memory-swap).
TRIMUS_PLUGIN_CPU_LIMIT 0.5 --cpus.
TRIMUS_PLUGIN_PIDS_LIMIT 100 --pids-limit.
TRIMUS_PLUGIN_SANDBOX_SECURITY_OPTS (none) Comma-separated extra --security-opt values.
TRIMUS_PLUGIN_BINARY_ALLOWLIST (empty = allow all) Opt-in comma-separated allowlist of plugin binaries permitted to run. Entries containing / must match binary_path exactly; bare entries match the binary's basename. When set, a non-listed binary is refused at start.

If the Docker CLI isn't on PATH, the host falls back to a bare subprocess with a one-shot warning (so CI runners and dev macOS without Docker still work, with the security guarantee weakened). TRIMUS_PLUGIN_SANDBOX=disabled does the same intentionally. On StopPlugin, the host both cancels the process and best-effort docker stops the named container (trimus-plugin-<id>).

JSON-Lines Protocol

All communication between the host and plugin occurs over JSON-lines: each message is a single line of JSON terminated by \n. Messages use the envelope format:

{"type": "<message_type>", "payload": { ... }}

The maximum message size is 1 MB (enforced by the buffered reader in internal/plugins/protocol.go).

Host -> Plugin Messages

init

Sent immediately after the subprocess starts. The plugin must respond with ready.

{
  "type": "init",
  "payload": {
    "plugin_id": "550e8400-e29b-41d4-a716-446655440000",
    "company_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "config": { "api_token": "...", "org": "acme" },
    "host_capabilities": ["http_fetch", "log"]
  }
}

host_capabilities advertises this plugin's host-service allowlist (see Host-service capability allowlist) so the plugin can condition behavior on what host_request services are permitted, rather than discovering denials per call.

health_check

Periodic liveness probe. The plugin should respond promptly (no payload required).

{
  "type": "health_check",
  "payload": {}
}

invoke_tool

Requests the plugin to execute a registered tool. The plugin must respond with tool_result using the same request_id.

{
  "type": "invoke_tool",
  "payload": {
    "tool": "list-repos",
    "input": { "org": "acme", "limit": 10 },
    "request_id": "req-abc-123"
  }
}

trigger_event

Notifies the plugin of an event matching its subscription patterns.

{
  "type": "trigger_event",
  "payload": {
    "event_type": "issue.created",
    "data": { "issue_id": "...", "title": "Bug in auth flow" }
  }
}

run_job

Triggers a scheduled job registered by the plugin.

{
  "type": "run_job",
  "payload": {
    "job_name": "sync-users"
  }
}

deliver_webhook

Delivers an inbound webhook to the plugin.

{
  "type": "deliver_webhook",
  "payload": {
    "path": "github-events",
    "headers": { "Content-Type": "application/json", "X-Hub-Signature-256": "sha256=..." },
    "body": "{\"action\":\"opened\",\"issue\":{...}}"
  }
}

shutdown

Requests graceful shutdown. The plugin should clean up and exit. If the plugin doesn't exit within 5 seconds, the host kills the process.

{
  "type": "shutdown",
  "payload": {}
}

Plugin -> Host Messages

ready

Sent once after receiving init. Contains the plugin manifest declaring all capabilities.

{
  "type": "ready",
  "payload": {
    "manifest": {
      "name": "GitHub Integration",
      "tools": [
        {
          "name": "list-repos",
          "namespace": "github",
          "description": "List repositories for an organization",
          "input_schema": {
            "type": "object",
            "properties": {
              "org": { "type": "string" },
              "limit": { "type": "integer", "default": 10 }
            },
            "required": ["org"]
          }
        }
      ],
      "events": ["issue.*", "agent.started"],
      "jobs": [
        { "name": "sync-users", "schedule": "0 */6 * * *" }
      ],
      "webhooks": [
        { "path": "github-events" }
      ]
    }
  }
}

tool_result

Response to an invoke_tool message. Must include the matching request_id.

{
  "type": "tool_result",
  "payload": {
    "request_id": "req-abc-123",
    "result": { "repos": ["trimus", "platform", "core"] },
    "error": null
  }
}

On failure, set error to a string describing the problem:

{
  "type": "tool_result",
  "payload": {
    "request_id": "req-abc-123",
    "result": null,
    "error": "GitHub API returned 403: rate limit exceeded"
  }
}

event_handled

Acknowledgment after processing a trigger_event.

{
  "type": "event_handled",
  "payload": {
    "event_type": "issue.created",
    "result": { "action": "labeled", "labels": ["bug"] }
  }
}

job_result

Response after completing a run_job.

{
  "type": "job_result",
  "payload": {
    "job_name": "sync-users",
    "result": { "synced": 42, "errors": 0 }
  }
}

host_request

Requests a service from the host (HTTP fetch, logging). The host responds with a tool_result message containing the service response.

{
  "type": "host_request",
  "payload": {
    "service": "http_fetch",
    "request_id": "svc-001",
    "args": {
      "url": "https://api.github.com/orgs/acme/repos",
      "method": "GET",
      "headers": { "Authorization": "Bearer ghp_..." }
    }
  }
}

log

Sends a log message to the host's structured logger.

{
  "type": "log",
  "payload": {
    "level": "info",
    "message": "Successfully synced 42 users from GitHub"
  }
}

error

Reports a plugin error to the host.

{
  "type": "error",
  "payload": {
    "message": "failed to connect to GitHub API",
    "code": "GITHUB_AUTH_FAILED"
  }
}

Plugin Manifest

The manifest is the plugin's declaration of capabilities, sent in the ready message. It tells the host what the plugin provides.

// From internal/plugins/protocol.go
type PluginManifest struct {
    Name     string            `json:"name"`     // Human-readable plugin name
    Tools    []ManifestTool    `json:"tools"`    // Tools available for agent use
    Events   []string          `json:"events"`   // Event subscription patterns
    Jobs     []ManifestJob     `json:"jobs"`     // Cron-scheduled jobs
    Webhooks []ManifestWebhook `json:"webhooks"` // Inbound webhook paths
}

type ManifestTool struct {
    Name        string          `json:"name"`         // Tool name (e.g., "list-repos")
    Namespace   string          `json:"namespace"`    // Namespace (e.g., "github")
    Description string          `json:"description"`  // Human-readable description
    InputSchema json.RawMessage `json:"input_schema"` // JSON Schema for tool input
}

type ManifestJob struct {
    Name     string          `json:"name"`            // Job name
    Schedule string          `json:"schedule"`        // 5-field cron expression
    Config   json.RawMessage `json:"config,omitempty"`// Optional job config
}

type ManifestWebhook struct {
    Path string `json:"path"` // Webhook path (used in URL routing)
}

Creating a Plugin

A working example plugin is available at examples/plugins/hello-world/. It demonstrates all protocol features: custom tools, event subscriptions, scheduled jobs, host service calls, and error handling. See its README for build and install instructions.

Example 1: Hello World Plugin

This minimal plugin registers a single tool and handles events.

package main

import (
    "bufio"
    "encoding/json"
    "fmt"
    "os"
)

// Message is the JSON-lines envelope.
type Message struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`
}

// send writes a JSON-lines message to stdout.
func send(msgType string, payload interface{}) {
    payloadBytes, _ := json.Marshal(payload)
    msg := Message{Type: msgType, Payload: payloadBytes}
    line, _ := json.Marshal(msg)
    fmt.Fprintln(os.Stdout, string(line))
}

func main() {
    scanner := bufio.NewScanner(os.Stdin)
    scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)

    for scanner.Scan() {
        var msg Message
        if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
            send("error", map[string]string{"message": err.Error(), "code": "PARSE_ERROR"})
            continue
        }

        switch msg.Type {
        case "init":
            // Respond with ready + manifest
            send("ready", map[string]interface{}{
                "manifest": map[string]interface{}{
                    "name": "Hello World",
                    "tools": []map[string]interface{}{
                        {
                            "name":        "greet",
                            "namespace":   "hello",
                            "description": "Greets a person by name",
                            "input_schema": map[string]interface{}{
                                "type": "object",
                                "properties": map[string]interface{}{
                                    "name": map[string]string{"type": "string"},
                                },
                                "required": []string{"name"},
                            },
                        },
                    },
                    "events":   []string{"issue.created"},
                    "jobs":     []interface{}{},
                    "webhooks": []interface{}{},
                },
            })

        case "invoke_tool":
            var payload struct {
                Tool      string          `json:"tool"`
                Input     json.RawMessage `json:"input"`
                RequestID string          `json:"request_id"`
            }
            json.Unmarshal(msg.Payload, &payload)

            var input struct {
                Name string `json:"name"`
            }
            json.Unmarshal(payload.Input, &input)

            greeting := fmt.Sprintf("Hello, %s! Welcome to Trimus.", input.Name)
            send("tool_result", map[string]interface{}{
                "request_id": payload.RequestID,
                "result":     map[string]string{"greeting": greeting},
                "error":      nil,
            })

        case "trigger_event":
            var payload struct {
                EventType string          `json:"event_type"`
                Data      json.RawMessage `json:"data"`
            }
            json.Unmarshal(msg.Payload, &payload)

            send("log", map[string]string{
                "level":   "info",
                "message": fmt.Sprintf("Received event: %s", payload.EventType),
            })
            send("event_handled", map[string]interface{}{
                "event_type": payload.EventType,
                "result":     map[string]string{"action": "logged"},
            })

        case "health_check":
            // No response required, but we can log
            send("log", map[string]string{"level": "debug", "message": "health check ok"})

        case "shutdown":
            os.Exit(0)
        }
    }
}

Build and register it:

go build -o bin/hello-plugin ./cmd/hello-plugin

curl -X POST http://localhost:3100/api/companies/{companyId}/plugins \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Hello World",
    "slug": "hello-world",
    "description": "A simple greeting plugin",
    "binary_path": "/path/to/bin/hello-plugin"
  }'

Example 2: GitHub Integration Plugin

A more realistic plugin that provides tools for interacting with GitHub and responds to webhooks.

package main

import (
    "bufio"
    "encoding/json"
    "fmt"
    "os"
)

type Message struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`
}

var (
    pluginID  string
    companyID string
    apiToken  string
)

func send(msgType string, payload interface{}) {
    payloadBytes, _ := json.Marshal(payload)
    msg := Message{Type: msgType, Payload: payloadBytes}
    line, _ := json.Marshal(msg)
    fmt.Fprintln(os.Stdout, string(line))
}

// requestHTTP asks the host to make an HTTP request (SSRF-protected).
func requestHTTP(requestID, url, method string, headers map[string]string) {
    send("host_request", map[string]interface{}{
        "service":    "http_fetch",
        "request_id": requestID,
        "args": map[string]interface{}{
            "url":     url,
            "method":  method,
            "headers": headers,
        },
    })
}

func main() {
    scanner := bufio.NewScanner(os.Stdin)
    scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)

    for scanner.Scan() {
        var msg Message
        if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
            send("error", map[string]string{"message": err.Error(), "code": "PARSE_ERROR"})
            continue
        }

        switch msg.Type {
        case "init":
            var init struct {
                PluginID  string          `json:"plugin_id"`
                CompanyID string          `json:"company_id"`
                Config    json.RawMessage `json:"config"`
            }
            json.Unmarshal(msg.Payload, &init)
            pluginID = init.PluginID
            companyID = init.CompanyID

            var config struct {
                APIToken string `json:"api_token"`
            }
            json.Unmarshal(init.Config, &config)
            apiToken = config.APIToken

            send("ready", map[string]interface{}{
                "manifest": map[string]interface{}{
                    "name": "GitHub Integration",
                    "tools": []map[string]interface{}{
                        {
                            "name":        "list-repos",
                            "namespace":   "github",
                            "description": "List repositories for a GitHub organization",
                            "input_schema": map[string]interface{}{
                                "type": "object",
                                "properties": map[string]interface{}{
                                    "org":   map[string]string{"type": "string"},
                                    "limit": map[string]interface{}{"type": "integer", "default": 10},
                                },
                                "required": []string{"org"},
                            },
                        },
                        {
                            "name":        "create-issue",
                            "namespace":   "github",
                            "description": "Create an issue in a GitHub repository",
                            "input_schema": map[string]interface{}{
                                "type": "object",
                                "properties": map[string]interface{}{
                                    "owner": map[string]string{"type": "string"},
                                    "repo":  map[string]string{"type": "string"},
                                    "title": map[string]string{"type": "string"},
                                    "body":  map[string]string{"type": "string"},
                                },
                                "required": []string{"owner", "repo", "title"},
                            },
                        },
                    },
                    "events": []string{"issue.created", "issue.updated"},
                    "jobs": []map[string]interface{}{
                        {
                            "name":     "sync-repos",
                            "schedule": "0 */6 * * *",
                        },
                    },
                    "webhooks": []map[string]string{
                        {"path": "github-events"},
                    },
                },
            })

        case "invoke_tool":
            var payload struct {
                Tool      string          `json:"tool"`
                Input     json.RawMessage `json:"input"`
                RequestID string          `json:"request_id"`
            }
            json.Unmarshal(msg.Payload, &payload)

            switch payload.Tool {
            case "list-repos":
                var input struct {
                    Org   string `json:"org"`
                    Limit int    `json:"limit"`
                }
                json.Unmarshal(payload.Input, &input)
                if input.Limit == 0 {
                    input.Limit = 10
                }

                // Use host_request to make SSRF-protected HTTP call
                requestHTTP(
                    payload.RequestID,
                    fmt.Sprintf("https://api.github.com/orgs/%s/repos?per_page=%d", input.Org, input.Limit),
                    "GET",
                    map[string]string{
                        "Authorization": "Bearer " + apiToken,
                        "Accept":        "application/vnd.github+json",
                    },
                )
                // The host will send back a tool_result with the HTTP response;
                // in a real plugin you'd parse that and send your own tool_result.
                // For simplicity, we handle the response inline.

            case "create-issue":
                var input struct {
                    Owner string `json:"owner"`
                    Repo  string `json:"repo"`
                    Title string `json:"title"`
                    Body  string `json:"body"`
                }
                json.Unmarshal(payload.Input, &input)

                body, _ := json.Marshal(map[string]string{
                    "title": input.Title,
                    "body":  input.Body,
                })

                send("host_request", map[string]interface{}{
                    "service":    "http_fetch",
                    "request_id": payload.RequestID,
                    "args": map[string]interface{}{
                        "url":    fmt.Sprintf("https://api.github.com/repos/%s/%s/issues", input.Owner, input.Repo),
                        "method": "POST",
                        "headers": map[string]string{
                            "Authorization": "Bearer " + apiToken,
                            "Accept":        "application/vnd.github+json",
                            "Content-Type":  "application/json",
                        },
                        "body": string(body),
                    },
                })

            default:
                errMsg := fmt.Sprintf("unknown tool: %s", payload.Tool)
                send("tool_result", map[string]interface{}{
                    "request_id": payload.RequestID,
                    "result":     nil,
                    "error":      errMsg,
                })
            }

        case "trigger_event":
            var payload struct {
                EventType string          `json:"event_type"`
                Data      json.RawMessage `json:"data"`
            }
            json.Unmarshal(msg.Payload, &payload)

            send("log", map[string]string{
                "level":   "info",
                "message": fmt.Sprintf("GitHub plugin received event: %s", payload.EventType),
            })
            send("event_handled", map[string]interface{}{
                "event_type": payload.EventType,
                "result":     map[string]string{"status": "processed"},
            })

        case "run_job":
            var payload struct {
                JobName string `json:"job_name"`
            }
            json.Unmarshal(msg.Payload, &payload)

            send("log", map[string]string{
                "level":   "info",
                "message": fmt.Sprintf("Running job: %s", payload.JobName),
            })
            send("job_result", map[string]interface{}{
                "job_name": payload.JobName,
                "result":   map[string]interface{}{"synced": 0, "status": "ok"},
            })

        case "deliver_webhook":
            var payload struct {
                Path    string            `json:"path"`
                Headers map[string]string `json:"headers"`
                Body    string            `json:"body"`
            }
            json.Unmarshal(msg.Payload, &payload)

            send("log", map[string]string{
                "level":   "info",
                "message": fmt.Sprintf("Received webhook on path: %s", payload.Path),
            })

        case "health_check":
            // Acknowledge silently

        case "shutdown":
            send("log", map[string]string{"level": "info", "message": "GitHub plugin shutting down"})
            os.Exit(0)
        }
    }
}

This plugin, when registered with "slug": "github", makes its tools available as github:list-repos and github:create-issue.

Tool Contributions

Plugins register tools through the manifest in the ready message. Each tool gets a qualified name in the format {namespace}:{name}.

Namespacing

The namespace is typically the plugin's slug. For example, a plugin with slug github registering a tool named list-repos creates the qualified name github:list-repos. This prevents name collisions between plugins.

Input schemas

Tools declare their input format using JSON Schema. The schema is stored in the input_schema field and used for documentation and validation.

How tool invocation works

  1. An agent or API call requests a tool by qualified name (e.g., github:list-repos).
  2. The PluginToolRegistry resolves the qualified name to find which plugin provides it.
  3. The host sends an invoke_tool message with a unique request_id.
  4. The plugin processes the request and responds with tool_result containing the same request_id.
  5. The host matches the response to the waiting request via the request_id and returns the result.

Tool invocation is synchronous from the caller's perspective -- the host waits for the tool_result (respecting the request context's timeout).

Invoking tools via API

Plugin-scoped (you know which plugin):

curl -X POST http://localhost:3100/api/plugins/{pluginId}/tools/invoke \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{"tool": "list-repos", "input": {"org": "acme"}}'

Global (resolve by qualified name):

curl -X POST http://localhost:3100/api/companies/{companyId}/plugins/tools/invoke \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{"tool": "github:list-repos", "input": {"org": "acme"}}'

Event Bus

The event bus lets plugins subscribe to system events using pattern matching.

Subscribing to events

Plugins declare event subscriptions in their manifest:

{
  "events": ["issue.created", "issue.*", "*.created", "*"]
}

Pattern matching rules

Patterns use dot-separated segments with wildcard support:

Pattern Matches
* All events
issue.created Exactly issue.created
issue.* issue.created, issue.updated, issue.deleted
*.created issue.created, project.created, agent.created
issue.*.done issue.review.done, issue.build.done

Rules: - * as the entire pattern matches everything. - Wildcard * in a segment matches any single segment. - Pattern and event type must have the same number of dot-separated segments (except the bare *).

Company isolation

Events are scoped to companies. A plugin in company A will never receive events from company B. The event bus filters subscriptions by company_id before dispatching.

Implementation details

The event bus is in-memory (internal/plugins/event_bus.go). When Publish() is called:

  1. It iterates all subscriptions.
  2. Filters by matching company_id.
  3. Tests each subscription's EventPattern against the event type using MatchPattern().
  4. For each match, calls host.SendEvent() to deliver a trigger_event message to the plugin.

Scheduled Jobs

Plugins can register cron-scheduled jobs that the host triggers automatically.

Registering jobs

Jobs are declared in the plugin manifest:

{
  "jobs": [
    { "name": "sync-users",  "schedule": "0 */6 * * *" },
    { "name": "daily-report", "schedule": "0 9 * * 1-5" }
  ]
}

Cron expression format

Trimus uses 5-field cron expressions (via robfig/cron/v3):

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *

Examples:

Expression Meaning
*/5 * * * * Every 5 minutes
0 */6 * * * Every 6 hours
0 9 * * 1-5 Weekdays at 9 AM
30 2 * * * Daily at 2:30 AM
0 0 1 * * First day of each month

How scheduling works

The PluginJobScheduler (internal/plugins/job_scheduler.go) runs a background goroutine that ticks every 1 minute. On each tick:

  1. It checks all registered jobs.
  2. If now >= NextRunAt, the job is due.
  3. The scheduler sends a run_job message to the plugin.
  4. Updates LastRunAt and calculates the next NextRunAt from the cron expression.

Jobs are unregistered when a plugin is stopped.

Webhooks

Plugins can register inbound webhook endpoints that external services can POST to.

Registering webhooks

Webhooks are declared in the manifest:

{
  "webhooks": [
    { "path": "github-events" }
  ]
}

This creates an endpoint at:

POST /api/companies/{companyId}/plugins/webhooks/github-events

How webhook delivery works

  1. An external service (e.g., GitHub) sends a POST to the webhook URL.
  2. The handler in internal/handler/plugin_handlers.go looks up the webhook by path.
  3. It reads the request body (up to 10 MB) and collects headers.
  4. A deliver_webhook message is sent to the owning plugin with the path, headers, and body.

Webhook secrets

The plugin_webhooks table has a secret_hash column for optional webhook signature verification. This can be used to validate that incoming webhooks are authentic (e.g., GitHub's X-Hub-Signature-256 header).

Host Services

Plugins can request services from the host by sending host_request messages. The host processes these asynchronously and responds with a tool_result message.

HTTP Fetch

Make outbound HTTP requests with SSRF protection.

Request:

{
  "type": "host_request",
  "payload": {
    "service": "http_fetch",
    "request_id": "fetch-001",
    "args": {
      "url": "https://api.github.com/repos/trimus-org/trimus",
      "method": "GET",
      "headers": { "Authorization": "Bearer ghp_..." },
      "body": ""
    }
  }
}

Response (sent back as tool_result):

{
  "type": "tool_result",
  "payload": {
    "request_id": "fetch-001",
    "result": {
      "status": 200,
      "headers": { "Content-Type": "application/json" },
      "body": "{\"id\":123,...}"
    },
    "error": null
  }
}

Constraints: - Default method: GET - Response body limit: 10 MB - Request timeout: 30 seconds (10-second dial timeout) - SSRF protection blocks private/reserved IPs (see Security section)

Logging

Send structured log messages to the host.

Request:

{
  "type": "host_request",
  "payload": {
    "service": "log",
    "request_id": "log-001",
    "args": {
      "level": "info",
      "message": "Successfully synced 42 users"
    }
  }
}

Logs appear in the host's structured JSON output with the plugin ID attached.

Note: you can also use the simpler log message type directly instead of host_request.

API Reference

All API endpoints require Bearer token authentication. Plugin-scoped routes are under /api/plugins/{pluginId}, company-scoped routes are under /api/companies/{companyId}.

Company-Scoped Endpoints

List Plugins

GET /api/companies/{companyId}/plugins

Returns all plugins for the company.

Create Plugin

POST /api/companies/{companyId}/plugins
Content-Type: application/json

{
  "name": "GitHub Integration",       // required
  "slug": "github",                   // required, unique per company
  "description": "Connects to GitHub",
  "version": "1.0.0",
  "binary_path": "/opt/plugins/github-plugin",
  "binary_hash": "sha256:abc123...",
  "config": { "api_token": "ghp_..." }
}

Returns 201 Created with the plugin object.

List All Plugin Tools

GET /api/companies/{companyId}/plugins/tools

Returns all enabled tools across all plugins for the company.

Invoke Tool by Qualified Name

POST /api/companies/{companyId}/plugins/tools/invoke
Content-Type: application/json

{
  "tool": "github:list-repos",
  "input": { "org": "acme" }
}

Resolves the tool by namespace:name, finds the owning plugin, and invokes it.

Deliver Webhook

POST /api/companies/{companyId}/plugins/webhooks/{path}

Delivers the request body and headers to the plugin that registered the webhook path.

Plugin-Scoped Endpoints

Get Plugin

GET /api/plugins/{pluginId}

Update Plugin

PUT /api/plugins/{pluginId}
Content-Type: application/json

{
  "name": "GitHub Integration v2",
  "version": "2.0.0",
  "binary_path": "/opt/plugins/github-plugin-v2",
  "config": { "api_token": "ghp_new..." },
  "enabled": true
}

All fields are optional (partial update).

Delete Plugin

DELETE /api/plugins/{pluginId}

Returns 204 No Content. Stops the process if running and cascades deletes to all related tables.

Start Plugin

POST /api/plugins/{pluginId}/start

Spawns the subprocess, sends init, and marks the plugin as running.

Stop Plugin

POST /api/plugins/{pluginId}/stop

Sends shutdown, waits up to 5 seconds, then kills the process if necessary. Unregisters all tools.

Restart Plugin

POST /api/plugins/{pluginId}/restart

Stops and re-starts the plugin, preserving its configuration.

Allow / Ban / Rescind ban (approval)

POST /api/plugins/{pluginId}/allow        # → approval_state=allowed, snapshots binary SHA-256
POST /api/plugins/{pluginId}/ban          # → approval_state=banned
POST /api/plugins/{pluginId}/rescind-ban  # banned → pending

These govern whether the plugin may start (see Approval & binary integrity). They self-gate on admin role inside the handler so a denied attempt still writes an audit row. Read the audit trail at GET /api/companies/{companyId}/plugins/audit.

Health Check

GET /api/plugins/{pluginId}/health

Returns:

{ "status": "healthy", "state": "ready" }

Or if not running:

{ "status": "not_running" }

List Plugin Tools

GET /api/plugins/{pluginId}/tools

Invoke Plugin Tool

POST /api/plugins/{pluginId}/tools/invoke
Content-Type: application/json

{
  "tool": "list-repos",
  "input": { "org": "acme" }
}

Note: uses the plain tool name (not qualified) since the plugin is already specified.

List Plugin Events

GET /api/plugins/{pluginId}/events?limit=50

Returns recent events for the plugin, ordered newest-first. Limit defaults to 50, max 500.

List Plugin Jobs

GET /api/plugins/{pluginId}/jobs

List Plugin Webhooks

GET /api/plugins/{pluginId}/webhooks

Installing a Plugin

Step 1: Register

curl -X POST http://localhost:3100/api/companies/{companyId}/plugins \
  -H "Authorization: Bearer sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My Plugin",
    "slug": "my-plugin",
    "description": "Does useful things",
    "binary_path": "/opt/plugins/my-plugin",
    "config": {"key": "value"}
  }'

The plugin starts in the installed lifecycle state and pending approval state.

Step 2: Allow (admin)

A pending plugin cannot start. An admin must allow it first, which also snapshots the binary's SHA-256 as the approved hash:

curl -X POST http://localhost:3100/api/plugins/{pluginId}/allow \
  -H "Authorization: Bearer sk_..."

See Approval & binary integrity for the full state machine. Skip this step and Step 3 returns an error: plugin ... is pending approval.

Step 3: Start

curl -X POST http://localhost:3100/api/plugins/{pluginId}/start \
  -H "Authorization: Bearer sk_..."

The host re-checks approval + binary hash, spawns the subprocess inside the Docker sandbox (unless disabled), sends init, and waits for the ready message. Tools, event subscriptions, jobs, and webhooks are registered from the manifest.

Step 4: Use

  • Invoke tools: POST /api/plugins/{pluginId}/tools/invoke
  • Events are delivered automatically based on subscriptions.
  • Jobs run on their cron schedules.
  • Webhooks are available at their registered paths.

Step 5: Stop

curl -X POST http://localhost:3100/api/plugins/{pluginId}/stop \
  -H "Authorization: Bearer sk_..."

Step 6: Uninstall

curl -X DELETE http://localhost:3100/api/plugins/{pluginId} \
  -H "Authorization: Bearer sk_..."

UI Pages

Trimus includes an HTMX-powered plugin management UI. Templates are in internal/web/templates/plugins/.

Plugin List (/companies/{companyId}/plugins)

Shows all plugins with their name, slug, version, state, and enabled status. Includes a modal form to install new plugins and start/stop buttons.

Plugin Detail (/plugins/{pluginId})

Comprehensive view of a single plugin:

  • Properties sidebar: state, slug, version, enabled, binary path, error message, timestamps
  • Tools section: registered tools with name, namespace, and description
  • Scheduled Jobs: job names, cron schedules, last/next run times
  • Webhooks: registered paths with enabled status
  • Recent Events: auto-refreshing event log (polls every 5 seconds via HTMX)

Global Tool Browser (/companies/{companyId}/plugins/tools)

Lists all tools across all plugins for the company. Shows qualified names (namespace:name), descriptions, and which plugin provides each tool.

Event Log (/plugins/{pluginId}/events)

Dedicated event log viewer for a single plugin. Shows event type, timestamp, and event ID. Auto-refreshes every 5 seconds.

Security Considerations

Approval gate + binary integrity

A plugin cannot start until an admin allows it, and the allowed binary's SHA-256 is re-verified on every start — a swapped binary auto-reverts to pending and is refused. Full detail in Approval & binary integrity. This is the first line of defence: nothing runs that an admin hasn't vouched for by hash.

Docker sandbox

By default each plugin runs in a Docker container with a read-only root, no network, and CPU/memory/PID caps. Full detail in The Docker sandbox. The --network none posture means a plugin reaches the outside world only through the SSRF-guarded, allowlist-gated http_fetch host service — never by opening its own sockets. Operators can disable the sandbox with TRIMUS_PLUGIN_SANDBOX=disabled, which is logged as a security downgrade.

Host-service capability allowlist

Each plugin's host_capabilities JSONB array gates which host_request services it may call (default ["http_fetch","log"]; set to [] for a read-only plugin). A request for a service not in the list returns a structured error and is logged. See Host Services.

Process Isolation

Each plugin runs as a separate OS process (inside the sandbox container by default). Stdin and stdout are piped for JSON-lines communication, and stderr is forwarded to the host logger. If a plugin crashes, only that plugin is affected -- the host and other plugins continue running. On graceful shutdown, the host sends a shutdown message and waits 5 seconds before force-killing.

SSRF Protection

The http_fetch host service validates URLs before making requests (internal/plugins/host_services.go). The following are blocked:

Blocked Range Reason
10.0.0.0/8 RFC 1918 private
172.16.0.0/12 RFC 1918 private
192.168.0.0/16 RFC 1918 private
127.0.0.0/8 Loopback
169.254.0.0/16 Link-local
169.254.169.254 AWS metadata endpoint
metadata.google.internal GCP metadata endpoint
::1/128 IPv6 loopback
fc00::/7 IPv6 private
fe80::/10 IPv6 link-local

The validator resolves hostnames to IPs before checking, preventing DNS rebinding attacks against private infrastructure.

Secret Handling

  • Plugin configuration (including secrets like API tokens) is stored in the config JSONB column of the plugins table.
  • The host's log redaction middleware strips sk_*, pk_*, and Bearer tokens from log output.
  • Webhook secrets can be stored as hashes in the plugin_webhooks.secret_hash column.

Company Isolation

  • All plugin data is scoped to a company_id.
  • Plugin slugs are unique per company (database constraint).
  • The event bus only delivers events to plugins within the same company.
  • The RequireCompanyAccess middleware prevents cross-company access via the API.

Troubleshooting

Plugin won't start

  • Check approval state first. A pending or banned plugin refuses to start: plugin ... is pending approval or ... is banned. Run POST /api/plugins/{id}/allow (admin) — see Approval & binary integrity.
  • Binary hash mismatch. If you rebuilt/replaced the binary after allowing it, the next start fails with binary hash changed since approval (auto-reverted to pending; re-approve to re-enable). Re-run /allow to snapshot the new binary.
  • Binary allowlist: if TRIMUS_PLUGIN_BINARY_ALLOWLIST is set, the plugin's binary_path (or basename) must be listed; otherwise start fails with does not include <path>.
  • Check binary_path: Ensure the path points to a valid executable. The StartPlugin handler rejects plugins with empty binary_path.
  • Check permissions: The binary must be executable by the user running Trimus (and mountable into the sandbox container when the sandbox is on).
  • Docker unavailable / sandbox: If the sandbox is enabled but the Docker CLI isn't on PATH, the host logs a warning and falls back to a bare subprocess. To run intentionally without Docker, set TRIMUS_PLUGIN_SANDBOX=disabled.
  • Check logs: Look for "msg":"plugin started" or error messages in the JSON log output.
  • Check stderr: Plugin stderr is forwarded to the host logger. Look for crash output.

Plugin starts but tools aren't registered

  • Ensure the plugin responds to init with a ready message containing a valid manifest.
  • Check that tools in the manifest include name, namespace, description, and input_schema.
  • Look for "msg":"plugin ready" in the logs with a tools count.

Tool invocation times out

  • Tool invocation respects the request context timeout. If the plugin takes too long, the caller's context may expire.
  • Check that the plugin sends tool_result with the correct request_id.
  • Check for errors in the plugin's message handling loop.

Events not being delivered

  • Verify the plugin's manifest declares the correct event patterns in events.
  • Check that MatchPattern would match: the pattern and event type must have the same number of dot-separated segments.
  • Ensure the plugin and event source are in the same company.

Plugin shows as errored

  • The plugin process exited unexpectedly. Check stderr output in the host logs.
  • Common causes: unhandled panics, missing dependencies, configuration errors.
  • Fix the issue and restart: POST /api/plugins/{pluginId}/restart

host_request for http_fetch fails

  • SSRF protection may be blocking the URL. Check if the target resolves to a private IP.
  • The host enforces a 30-second timeout and 10 MB response limit.
  • Check the error in the tool_result response for details.

Debugging the protocol

Set LOG_LEVEL=debug to see all message routing:

LOG_LEVEL=debug DATABASE_URL=... ./bin/server

This logs every message received from plugins, including event_handled, job_result, and unknown message types.