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 (
pending→allowed/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
statecolumn above is the lifecycle state (is the process running?). It is distinct from the approval state (pending/allowed/banned, theapproval_statecolumn) 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/banned → allowed |
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 |
banned → pending |
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:
banned→ refuse:plugin ... is banned and cannot be started.pending→ refuse:plugin ... is pending approval — an admin must Allow it.allowed→ re-hash the binary atbinary_pathand compare withapproved_binary_hash:- Match → start proceeds.
- Mismatch → auto-revert to
pending, write an audit row recordingapprovedvsactualhashes, 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.gowires 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:
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).
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.
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.
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¶
- An agent or API call requests a tool by qualified name (e.g.,
github:list-repos). - The
PluginToolRegistryresolves the qualified name to find which plugin provides it. - The host sends an
invoke_toolmessage with a uniquerequest_id. - The plugin processes the request and responds with
tool_resultcontaining the samerequest_id. - The host matches the response to the waiting request via the
request_idand 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:
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:
- It iterates all subscriptions.
- Filters by matching
company_id. - Tests each subscription's
EventPatternagainst the event type usingMatchPattern(). - For each match, calls
host.SendEvent()to deliver atrigger_eventmessage 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:
- It checks all registered jobs.
- If
now >= NextRunAt, the job is due. - The scheduler sends a
run_jobmessage to the plugin. - Updates
LastRunAtand calculates the nextNextRunAtfrom 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:
This creates an endpoint at:
How webhook delivery works¶
- An external service (e.g., GitHub) sends a POST to the webhook URL.
- The handler in
internal/handler/plugin_handlers.golooks up the webhook by path. - It reads the request body (up to 10 MB) and collects headers.
- A
deliver_webhookmessage 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¶
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¶
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¶
Delivers the request body and headers to the plugin that registered the webhook path.
Plugin-Scoped Endpoints¶
Get Plugin¶
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¶
Returns 204 No Content. Stops the process if running and cascades deletes to all related tables.
Start Plugin¶
Spawns the subprocess, sends init, and marks the plugin as running.
Stop Plugin¶
Sends shutdown, waits up to 5 seconds, then kills the process if necessary. Unregisters all tools.
Restart Plugin¶
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¶
Returns:
Or if not running:
List Plugin 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¶
Returns recent events for the plugin, ordered newest-first. Limit defaults to 50, max 500.
List Plugin Jobs¶
List Plugin 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:
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¶
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¶
Step 6: Uninstall¶
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
configJSONB column of thepluginstable. - The host's log redaction middleware strips
sk_*,pk_*, andBearertokens from log output. - Webhook secrets can be stored as hashes in the
plugin_webhooks.secret_hashcolumn.
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
RequireCompanyAccessmiddleware prevents cross-company access via the API.
Troubleshooting¶
Plugin won't start¶
- Check approval state first. A
pendingorbannedplugin refuses to start:plugin ... is pending approvalor... is banned. RunPOST /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/allowto snapshot the new binary. - Binary allowlist: if
TRIMUS_PLUGIN_BINARY_ALLOWLISTis set, the plugin'sbinary_path(or basename) must be listed; otherwise start fails withdoes not include <path>. - Check
binary_path: Ensure the path points to a valid executable. TheStartPluginhandler rejects plugins with emptybinary_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, setTRIMUS_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
initwith areadymessage containing a valid manifest. - Check that
toolsin the manifest includename,namespace,description, andinput_schema. - Look for
"msg":"plugin ready"in the logs with atoolscount.
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_resultwith the correctrequest_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
MatchPatternwould 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_resultresponse for details.
Debugging the protocol¶
Set LOG_LEVEL=debug to see all message routing:
This logs every message received from plugins, including event_handled, job_result, and unknown message types.