Skip to content

Writing a Trimus plugin

A plugin is a separate OS process that Trimus starts and supervises inside a Docker sandbox. It communicates with the host over JSON-lines (newline-delimited JSON) on stdin/stdout. A plugin can:

  • Contribute tools that agents (and API callers) invoke.
  • Receive webhooks routed to plugin-defined logic.
  • Run scheduled jobs on a cron schedule.
  • Subscribe to events and react to them.

Use a plugin when a routine + agent isn't enough — typically when you need stateful logic, a long-lived connection to a third party, or a tool agents should call as part of their normal flow.

This page is the integrator's tour. The canonical, exhaustive reference — every message type, full Go and non-Go examples, the API surface, troubleshooting — is PLUGIN.md. The protocol types live in internal/plugins/protocol.go.

When to write one

Want to… Use instead?
React to one-off webhook events A routine trigger (webhooks-in.md)
Pull data in on a schedule A scheduled routine
Watch live events and react from outside Trimus An SSE consumer (sse-streaming.md)
Give agents a callable tool with real logic A plugin
Bridge a system that needs persistent state / OAuth A plugin

A plugin adds a process to operate and a binary to deploy — set the bar high. If a routine + a small relay does the job, do that first.

The model: JSON-lines over a sandboxed subprocess

There is no Go SDK package to import — a plugin is any executable that speaks the protocol on stdin/stdout. Every message is one line of JSON:

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

Max message size is 1 MB. The host drives the conversation; your plugin responds.

Host → plugin messages

Type Meaning You respond with
init Sent first. Carries plugin_id, company_id, config, and your host_capabilities allowlist. ready
health_check Liveness probe. (prompt response; no payload required)
invoke_tool Run a tool: {tool, input, request_id}. tool_result (same request_id)
trigger_event An event you subscribed to fired: {event_type, data}. event_handled
run_job A scheduled job is due: {job_name}. job_result
deliver_webhook An inbound webhook for one of your paths: {path, headers, body}. (handle it)
shutdown Graceful stop. Exit cleanly.

Plugin → host messages

Type Meaning
ready Sent once after init, carrying your manifest (see below).
tool_result {request_id, result, error} — the answer to invoke_tool.
event_handled / job_result Acknowledge an event / job.
host_request Ask the host for a service: {service, args, request_id} (see Host services).
log {level, message} — emit a structured log line via the host.
error {message, code} — report an error.

The manifest

On init, your plugin replies with ready carrying a manifest that declares everything it contributes:

{
  "type": "ready",
  "payload": {
    "manifest": {
      "name": "github",
      "tools": [
        {
          "name": "list-repos",
          "namespace": "github",
          "description": "List repositories for an org",
          "input_schema": { "type": "object", "properties": { "org": {"type":"string"} } }
        }
      ],
      "events":   ["issue.created", "issue.*"],
      "jobs":     [{ "name": "sync-users", "schedule": "0 */6 * * *" }],
      "webhooks": [{ "path": "github/push" }]
    }
  }
}

Tools get a qualified name namespace:name (typically slug:tool — e.g. github:list-repos) so tools from different plugins don't collide.

Registering a plugin

Register the plugin record in the database, then start it:

curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "name":"GitHub Integration",
    "slug":"github",
    "description":"Repo + PR tools for agents",
    "version":"1.0.0",
    "binary_path":"/usr/local/bin/trimus-plugin-github",
    "binary_hash":""
  }' \
  "$BASE/api/companies/$COMPANY/plugins"

Fields (from CreatePlugin): name and slug are required; description, version, binary_path, binary_hash, and config (JSON passed to the plugin on init) are optional. The caller must be a company admin. The binary_path must be readable + executable by the OS user running trimus-server (or, with the sandbox on, mountable into the container).

Approval gate — a plugin can't run until an admin allows it

A registered plugin starts in approval_state = pending and will not start until an admin allows it. The approval state machine (internal/handler/plugin_approval.go, enforced in internal/plugins/host.go):

Transition Endpoint Effect
Allow POST /api/plugins/{id}/allow State → allowed; snapshots the binary's SHA-256 as approved_binary_hash.
Ban POST /api/plugins/{id}/ban State → banned; refuses to start.
Rescind ban POST /api/plugins/{id}/rescind-ban Lifts a ban (back toward pending).

On every StartPlugin, the host:

  1. Refuses if state is pending or banned.
  2. Re-hashes the on-disk binary and compares it to approved_binary_hash. A mismatch auto-reverts the plugin to pending, writes an audit row, and refuses to start — so a binary swapped on disk after approval can't run silently. Re-approve to re-enable.

The allow/ban endpoints self-gate on admin role inside the handler (not via route middleware) so a denied attempt still writes an audit row. All transitions are visible at GET /api/plugins/{id} and the company's plugin audit log.

Starting, stopping, restarting

curl -s -X POST -H "Authorization: Bearer $TOKEN" "$BASE/api/plugins/$PLUGIN_ID/start"
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$BASE/api/plugins/$PLUGIN_ID/stop"
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$BASE/api/plugins/$PLUGIN_ID/restart"

These are admin-only. Operators can also start/stop/restart from the Plugins page. The lifecycle state (installedreadyrunningstopped/errored) is tracked separately from the approval state and recorded for auditing.

The Docker sandbox

By default, each plugin runs inside a hardened Docker container (source: internal/plugins/sandbox.go). Your plugin gets:

  • --read-only root filesystem with a small --tmpfs /tmp.
  • --network noneno network from the container itself. A plugin that needs to call out does so through the host's http_fetch service (below), not by opening sockets.
  • --security-opt no-new-privileges.
  • Resource caps: --memory 256m (+ matching --memory-swap), --cpus 0.5, --pids-limit 100.
  • A read-only bind mount of the plugin binary and a read-write /workspace for scratch.

Defaults are overridable via env (TRIMUS_PLUGIN_SANDBOX_IMAGE, TRIMUS_PLUGIN_MEMORY_LIMIT, TRIMUS_PLUGIN_CPU_LIMIT, TRIMUS_PLUGIN_PIDS_LIMIT, …). Operators can opt out entirely with TRIMUS_PLUGIN_SANDBOX=disabled (plugins then run as bare host subprocesses — a logged security downgrade). If the Docker CLI isn't on PATH, the host falls back to bare subprocess with a one-shot warning.

Tool invocation

Because the container has no network and there is no /plugin-tool action verb, tools are invoked over the host API, which routes the invoke_tool message to your process and waits for the matching tool_result:

# Plugin-scoped (you know which plugin) — admin:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"tool":"list-repos","input":{"org":"acme"}}' \
  "$BASE/api/plugins/$PLUGIN_ID/tools/invoke"

# Global (resolve by qualified name) — member+:
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"tool":"github:list-repos","input":{"org":"acme"}}' \
  "$BASE/api/companies/$COMPANY/plugins/tools/invoke"

Invocation is synchronous from the caller's perspective — the host blocks on the tool_result (respecting the request timeout).

Host services — the capability allowlist

Inside the sandbox your plugin can't reach the network or the host directly. It asks the host for a service via a host_request message, gated by a per-plugin host_capabilities allowlist (internal/plugins/host_services.go). The full surface is exactly two services:

Service What it does Notes
http_fetch Outbound HTTP {url, method, headers, body}{status, headers, body, truncated}. SSRF-protected: blocks RFC-1918, loopback, link-local, and cloud-metadata IPs. Body capped at 10 MB (truncated:true when clipped).
log Append a structured log line {level, message}. Read-only effect.

The default allowlist is ["http_fetch","log"]. An admin tightens it by editing the plugin's host_capabilities array (set to [] for a read-only plugin). A host_request for a service not in the allowlist returns a structured error and is logged. The allowlist is sent to your plugin on init (the host_capabilities field) so you can condition tool behaviour up front.

Webhooks, jobs, and events

  • Webhooks: declare a path in the manifest; Trimus delivers POSTs to /api/companies/{companyId}/plugins/webhooks/{path} as a deliver_webhook message (path, headers, body). See webhooks-in.md.
  • Jobs: declare {name, schedule} (5-field cron via robfig/cron); the host sends run_job at each tick.
  • Events: declare event patterns (issue.created, issue.*, *); the host sends trigger_event for matches. Events are company-scoped — a plugin in company A never sees company B's events.

What a plugin can and can't do

It can contribute tools, take webhooks, run jobs, react to events, fetch HTTP through the host (SSRF-guarded), and log.

It cannot:

  • Take down trimus-server — it's an isolated, resource-capped subprocess.
  • Reach the network directly — the container is --network none; use http_fetch.
  • Reach a host service outside its host_capabilities allowlist.
  • Run before an admin allows it, or run a binary that differs from the approved hash.

Where to next

  • PLUGIN.md — the full protocol, worked Go + non-Go examples, the complete API surface, and troubleshooting.
  • webhooks-in.md — the simpler "trigger → routine" alternative.
  • api-recipes.md — the API your plugin's webhook/job logic will call.
  • patterns.md — worked plugin-based integration examples.